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

+22 -17
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from nisse_dev.approvals import PlanBoard
from nisse_dev.cancel import Cancel
from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.config import AgentConfig
from nisse_dev.config import AgentConfig, Project
from nisse_dev.context import BudgetedClient
from nisse_dev.control import Orchestrator
from nisse_dev.delegation import Caps, Delegation
@@ -165,30 +165,34 @@ def make_llm_clients(
return normalized, passthrough
def build_host(config: AgentConfig) -> SSHHost:
"""The tool VM the workspace lives on (L13). Its known-hosts file and
connection socket live beside the state store."""
def build_host(config: AgentConfig, project: Project | None = None) -> SSHHost:
"""A project's build VM (L13; the only project's, if none is named). Its
known-hosts file and connection socket live beside the state store."""
host = (project or config.only_project()).build_host
return SSHHost(
config.build_host.address,
key=config.build_host.key,
user=config.build_host.user,
port=config.build_host.port,
host.address,
key=host.key,
user=host.user,
port=host.port,
state_dir=config.state_db.parent / "ssh",
)
def push_credential(config: AgentConfig) -> PushCredential | None:
"""What pushes go by: a push-only account's token over HTTPS, or a
deploy key over SSH (D4)."""
if config.push_user and config.push_token:
return HTTPSToken(config.push_user, config.push_token)
if config.push_key is None:
def push_credential(
config: AgentConfig, project: Project | None = None
) -> PushCredential | None:
"""What a project's pushes go by: a push-only account's token over
HTTPS, or a deploy key over SSH (D4)."""
project = project or config.only_project()
if project.push_user and project.push_token:
return HTTPSToken(project.push_user, project.push_token)
if project.push_key is None:
return None
try:
return SSHKey(config.push_key.read_text())
return SSHKey(project.push_key.read_text())
except OSError as exc:
raise RuntimeError(
f"cannot read the push key {config.push_key}: {exc}"
f"cannot read the push key {project.push_key}: {exc}"
) from exc
@@ -205,6 +209,7 @@ def prepare_workspace(
account: str,
remote: str,
credential: PushCredential | None,
project: Project | None = None,
) -> GitRepo:
"""Ensure the workspace on the build VM is a clone of the project repo.
@@ -213,7 +218,7 @@ def prepare_workspace(
Cloning here rather than when a work item starts is the single-project
shortcut that M1.4 and D3's fresh workspace replace.
"""
repo = GitRepo(host, config.workspace)
repo = GitRepo(host, (project or config.only_project()).workspace)
if not repo.exists():
repo.clone(remote, credential=credential)
repo.configure_identity(account, f"{account}@users.noreply.local")
+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,
+227 -100
View File
@@ -19,8 +19,8 @@ import re
import threading
import time
import uuid
from collections.abc import Callable, Iterable
from dataclasses import replace
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, replace
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage
@@ -61,9 +61,11 @@ from nisse_dev.worker import Worker
INTAKE_INTERVAL_S = 30.0
_GO = frozenset({"go", "approve"})
_NEXT = re.compile(r"(?:do|work on) #?(\d+) (?:next|first|now)")
# An issue, named: "#14", "14", or with its project, "sandbox#14" (D8).
_ISSUE = r"(?:([a-z0-9][a-z0-9-]*)#|#)?(\d+)"
_NEXT = re.compile(rf"(?:do|work on) {_ISSUE} (?:next|first|now)")
# A command that names its item applies to it, wherever it is said (L5).
_NAMED = re.compile(r"(go|approve|abandon|stop) #(\d+)")
_NAMED = re.compile(r"(go|approve|abandon|stop) (?:([a-z0-9][a-z0-9-]*))?#(\d+)")
# At top level these need an item; without one they are ambiguous (L5).
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"})
BUG_GRACE_S = 600.0
@@ -125,6 +127,33 @@ LABELS = {
}
@dataclass(frozen=True)
class Project:
"""What the loop needs of one project (D8): its repository, label
defaults, and its own workspace and milestones on its build VM."""
name: str
repo: str
workspace: Workspace | None = None
milestones: Milestones | None = None
labels: tuple[str, ...] = ()
def _restatement_problem(args: dict[str, object]) -> str | None:
"""L5, D1: a restatement is agreed to as it stands, so it asks nothing.
One that ends in a question is sent back: questions go by ask_human."""
text = str(args.get("text", "")).strip()
if not text:
return "a restatement needs text: what you take the request to mean"
if is_question(text):
return (
"not posted: a restatement asks no questions, so the operator can "
"agree to it as it stands. If you need an answer first, use "
"ask_human; otherwise restate it as a statement."
)
return None
class LoopAgent:
def __init__(
self,
@@ -132,16 +161,18 @@ class LoopAgent:
transport: Transport,
gitea: GiteaClient,
*,
repo: str,
project: str,
account: str,
operators: Iterable[str],
gitea_operators: Iterable[str],
driver: Driver,
workspace: Workspace | None,
worker: Worker,
cancel: Cancel,
oplog: OpLog,
projects: Sequence[Project] = (),
# One project, given directly: the same as projects=[Project(...)].
repo: str = "",
project: str = "",
workspace: Workspace | None = None,
roles: dict[str, str] | None = None,
milestones: Milestones | None = None,
reader: GoAheadReader | None = None,
@@ -152,32 +183,56 @@ class LoopAgent:
self._store = store
self._transport = transport
self._gitea = gitea
self._repo = repo
self._project = project
if not projects:
projects = [Project(project, repo, workspace, milestones)]
self._projects = {p.name: p for p in projects}
self._account = account
self._operators = frozenset(operators)
self._gitea_operators = frozenset(gitea_operators)
self._driver = driver
self._workspace = workspace
self._worker = worker
self._cancel = cancel
self._oplog = oplog
self._roles = roles or {}
self._milestones = milestones
self._reader = reader
self._grace = bug_grace_s
self._clock = clock
self._interval = intake_interval_s
# thread root -> revision on screen there, read on the transport's
# thread to stamp replies on arrival (L3); issue -> thread root, for
# a command that names its item.
# thread to stamp replies on arrival (L3); (project, issue) -> thread
# root, for a command that names its item.
self._shown: dict[str, int] = {}
self._threads: dict[int, str] = {}
self._threads: dict[tuple[str, int], str] = {}
self._active_thread = ""
# The project whose workspace was last worked on: switching to
# another parks it (L1). None after a boot: unknown, so all are.
self._last_project: str | None = None
self._running = False
self._stop = threading.Event()
self._intake_thread: threading.Thread | None = None
# -- projects (D8) ----------------------------------------------------------
def _at(self, item: WorkItem) -> Project:
return self._projects[item.scope or ""]
def _label(self, item: WorkItem) -> str:
"""How an item's issue is named to the operator: #14 with one
project, sandbox#14 with more, where #14 alone could be either."""
issue = loop_of(item).issue
return f"#{issue}" if len(self._projects) == 1 else f"{item.scope}#{issue}"
def _named(self, project: str | None, issue: int) -> list[WorkItem]:
"""Open items for ``issue``, in ``project`` if it is given."""
return [
i
for i in self._store.work_items("open")
if loop_of(i).issue == issue and (project is None or i.scope == project)
]
def _repos(self) -> str:
return ", ".join(p.repo for p in self._projects.values())
# -- lifecycle ----------------------------------------------------------
def start(self) -> None:
@@ -220,7 +275,7 @@ class LoopAgent:
self._reconcile_publishing()
for item in self._store.work_items("open"):
loop = loop_of(item)
self._threads[loop.issue] = loop.thread
self._threads[(item.scope or "", loop.issue)] = loop.thread
if item.position == RUNNING:
note = "I restarted while working on this. Reply to continue."
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
@@ -247,8 +302,11 @@ class LoopAgent:
continue
item = self._store.work_item(intent.work_item)
branch = str(intent.payload.get("branch", ""))
if item is None or item.scope not in self._projects:
self._store.abort_intent(intent.id, cause="item or project unknown")
continue
try:
pr = self._gitea.find_pull_request(self._repo, branch)
pr = self._gitea.find_pull_request(self._at(item).repo, branch)
except GiteaError:
continue # unverifiable: left pending, asked about below
if item is None or item.status != "open":
@@ -272,33 +330,39 @@ class LoopAgent:
# -- intake (D1) ---------------------------------------------------------
def intake(self) -> None:
"""Issues assigned to the agent become work items, once each."""
"""Issues assigned to the agent, in every project's repository,
become work items, once each."""
for project in self._projects.values():
self._intake(project)
self.watch()
self.check_blocked()
self.check_grace()
self._schedule()
def _intake(self, project: Project) -> None:
try:
issues = self._gitea.assigned_issues(self._repo, self._account)
issues = self._gitea.assigned_issues(project.repo, self._account)
except GiteaError as exc:
# L5: an outage delays intake, never fails anything.
self._oplog.emit("loop.intake_unavailable", cause=str(exc))
return
for issue in issues:
if item_for_issue(self._store, issue.number) is not None:
if item_for_issue(self._store, issue.number, project.name) is not None:
continue
if not self._from_an_operator(issue):
if not self._from_an_operator(project, issue):
self._oplog.emit(
"loop.intake_ignored",
cause="not filed or assigned by an operator",
issue=issue.number,
repo=project.repo,
)
continue
try:
blocked_by = self._gitea.open_dependencies(self._repo, issue.number)
blocked_by = self._gitea.open_dependencies(project.repo, issue.number)
except GiteaError as exc:
self._oplog.emit("loop.intake_unavailable", cause=str(exc))
continue # taken on the next round
self._open(issue, tuple(blocked_by))
self.watch()
self.check_blocked()
self.check_grace()
self._schedule()
self._open(project, issue, tuple(blocked_by))
# -- PR review and merge (D4, D5) -------------------------------------------
@@ -309,10 +373,11 @@ class LoopAgent:
loop = loop_of(item)
if not loop.pr:
continue
repo = self._at(item).repo
try:
pr = self._gitea.pull_request(self._repo, loop.pr)
comments = self._gitea.list_comments(self._repo, loop.pr)
notes = self._gitea.review_notes(self._repo, loop.pr)
pr = self._gitea.pull_request(repo, loop.pr)
comments = self._gitea.list_comments(repo, loop.pr)
notes = self._gitea.review_notes(repo, loop.pr)
except GiteaError as exc:
self._oplog.emit("loop.watch_unavailable", cause=str(exc))
continue
@@ -379,19 +444,20 @@ class LoopAgent:
if item.position not in (READY, BLOCKED):
continue
loop = loop_of(item)
issue = loop.issue
repo = self._at(item).repo
try:
blocked_by = tuple(self._gitea.open_dependencies(self._repo, issue))
blocked_by = tuple(self._gitea.open_dependencies(repo, loop.issue))
except GiteaError as exc:
self._oplog.emit("loop.watch_unavailable", cause=str(exc))
continue # as it was, until Gitea answers
position = BLOCKED if blocked_by else READY
if position == item.position and blocked_by == loop.blocked_by:
continue
name = self._label(item)
if position == READY:
said = f"#{issue} is unblocked: its dependencies have closed."
said = f"{name} is unblocked: its dependencies have closed."
else:
said = f"#{issue} is blocked by {', '.join(blocked_by)}."
said = f"{name} is blocked by {', '.join(blocked_by)}."
self._fact(item, said)
if position != item.position:
self._transport.post(said, thread=loop.thread)
@@ -421,38 +487,42 @@ class LoopAgent:
)
self._go(item, loop, "the bug grace period")
def _from_an_operator(self, issue: Issue) -> bool:
def _from_an_operator(self, project: Project, issue: Issue) -> bool:
"""L11: only an operator's issue, or one an operator assigned, is
an instruction."""
if issue.author in self._gitea_operators:
return True
try:
who = self._gitea.assigned_by(self._repo, issue.number, self._account)
who = self._gitea.assigned_by(project.repo, issue.number, self._account)
except GiteaError:
return False
return who in self._gitea_operators
def _open(self, issue: Issue, blocked_by: tuple[str, ...]) -> None:
item_id = f"{self._project}-{issue.number}-{uuid.uuid4().hex[:6]}"
cause = f"assigned {self._repo}#{issue.number}"
def _open(
self, project: Project, issue: Issue, blocked_by: tuple[str, ...]
) -> None:
item_id = f"{project.name}-{issue.number}-{uuid.uuid4().hex[:6]}"
cause = f"assigned {project.repo}#{issue.number}"
ahead = len(self._store.work_items("open"))
where = f", {ahead} ahead" if ahead else ""
if blocked_by:
where += f"; blocked by {', '.join(blocked_by)}"
root = self._transport.post(
f"Queued {self._repo}#{issue.number}: {issue.title}{where}"
f"Queued {project.repo}#{issue.number}: {issue.title}{where}"
)
# D8: the project's label defaults steer as if the issue had them.
labels = tuple(dict.fromkeys((*issue.labels, *project.labels)))
loop = Loop(
thread=root,
issue=issue.number,
blocked_by=blocked_by,
labels=tuple(issue.labels),
labels=labels,
)
self._threads[issue.number] = root
self._threads[(project.name, issue.number)] = root
self._store.open_work_item(
item_id,
title=issue.title,
scope=self._project,
scope=project.name,
position=BLOCKED if blocked_by else READY,
data={"loop": loop.to_data(), "roles": self._roles},
cause=cause,
@@ -460,9 +530,14 @@ class LoopAgent:
log = self._store.transcript(item_id, "orchestrator")
log.append(
Fact(
text=f"work item opened from {self._repo}#{issue.number} "
text=f"work item opened from {project.repo}#{issue.number} "
f"({issue.title}), filed by {issue.author}"
+ (f", labels: {', '.join(issue.labels)}" if issue.labels else "")
+ (f", labels: {', '.join(labels)}" if labels else "")
+ (
f" ({', '.join(project.labels)} by the project's default)"
if project.labels
else ""
)
)
)
text = f"{issue.title}\n\n{issue.body}".strip()
@@ -486,7 +561,14 @@ class LoopAgent:
named = _NAMED.fullmatch(command)
if named is not None:
command = named.group(1)
thread = self._threads.get(int(named.group(2)), thread)
project, issue = named.group(2), int(named.group(3))
threads = [
t
for (p, n), t in list(self._threads.items())
if n == issue and project in (None, p)
]
# Ambiguous across projects: no stamp; handle asks which.
thread = threads[0] if len(threads) == 1 else thread
if (
command == "stop"
and self._running
@@ -510,30 +592,27 @@ class LoopAgent:
UserMessage(text=text, source=f"transport:{msg.sender}")
)
self._transport.post(
self._put_first(int(order.group(1))), thread=msg.thread
self._put_first(order.group(1), int(order.group(2))),
thread=msg.thread,
)
self._schedule()
return
named = _NAMED.fullmatch(command)
if named is not None:
# L5: a command that names its item applies to it.
command, issue = named.group(1), int(named.group(2))
item = next(
(
i
for i in self._store.work_items("open")
if loop_of(i).issue == issue
),
None,
)
if item is None:
command = named.group(1)
project, issue = named.group(2), int(named.group(3))
found = self._named(project, issue)
if len(found) != 1:
self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
self._transport.post(
f"#{issue} isn't one of my open work items.", thread=msg.thread
self._not_one(found, project, issue, f"{command} {{}}"),
thread=msg.thread,
)
return
[item] = found
if item is None:
self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
@@ -547,7 +626,7 @@ class LoopAgent:
self._transport.post("Nothing is running.", thread=msg.thread)
else:
self._transport.post(
f"I take work from issues assigned to me in {self._repo}: "
f"I take work from issues assigned to me in {self._repos()}: "
"assign one and I'll pick it up. Reply in its thread to "
"talk about it.",
thread=msg.thread,
@@ -574,12 +653,12 @@ class LoopAgent:
# Stopping a run took effect on arrival; a stop is never an answer.
if not loop.note.startswith("stopped ("):
self._transport.post(
f"#{loop.issue} isn't running; nothing to stop.",
f"{self._label(item)} isn't running; nothing to stop.",
thread=loop.thread,
)
elif item.position == BLOCKED:
self._transport.post(
f"#{loop.issue} is blocked by {', '.join(loop.blocked_by)}; "
f"{self._label(item)} is blocked by {', '.join(loop.blocked_by)}; "
"I'll start on it when they close, or when you remove them.",
thread=loop.thread,
)
@@ -642,31 +721,44 @@ class LoopAgent:
items = self._store.work_items("open")
if not items:
return f"{command} what? I have no open work items."
listed = ", ".join(f"#{loop_of(i).issue} ({i.title})" for i in items)
listed = ", ".join(f"{self._label(i)} ({i.title})" for i in items)
return (
f"Which item? Say it in the item's thread, or name it "
f'("{command} #{loop_of(items[0]).issue}"). Open: {listed}.'
f'("{command} {self._label(items[0])}"). Open: {listed}.'
)
def _put_first(self, issue: int) -> str:
def _not_one(
self, found: list[WorkItem], project: str | None, issue: int, say: str
) -> str:
"""A named item that is none, or more than one (the same number in
two projects). ``say`` is the command, with {} for the item."""
name = f"{project}#{issue}" if project else f"#{issue}"
if not found:
return f"{name} isn't one of my open work items."
options = " or ".join(say.format(self._label(i)) for i in found)
return f"{name} is open in more than one project: say {options}."
def _put_first(self, project: str | None, issue: int) -> str:
"""The operator's "do #N next" (L1): it runs before every other
ready item, the latest such order first. It doesn't unblock."""
found = self._named(project, issue)
if len(found) != 1:
return self._not_one(found, project, issue, "do {} next")
[item] = found
name = self._label(item)
items = self._store.work_items("open")
item = next((i for i in items if loop_of(i).issue == issue), None)
if item is None:
return f"#{issue} isn't one of my open work items."
first = max(loop_of(i).first for i in items) + 1
loop = replace(loop_of(item), first=first)
save(self._store, item.id, item.position, loop, cause=f"#{issue} put first")
save(self._store, item.id, item.position, loop, cause=f"{name} put first")
self._fact(item, "the operator put this item first in the queue")
if item.position == BLOCKED:
return (
f"#{issue} goes first once it's unblocked "
f"{name} goes first once it's unblocked "
f"(blocked by {', '.join(loop.blocked_by)})."
)
if item.position == WAITING:
return f"#{issue} goes first, but it's waiting for {loop.wait}: {loop.note}"
return f"#{issue} is next."
return f"{name} goes first, but it's waiting for {loop.wait}: {loop.note}"
return f"{name} is next."
def _queue(self) -> list[WorkItem]:
"""Ready items in the order they run (L1): the operator's order
@@ -678,7 +770,10 @@ class LoopAgent:
def status(self) -> str:
items = self._store.work_items("open")
if not items:
return f"idle: no open work items. I take issues assigned to me in {self._repo}."
return (
"idle: no open work items. I take issues assigned to me in "
f"{self._repos()}."
)
queue = [i.id for i in self._queue()]
lines = []
for item in items:
@@ -693,7 +788,7 @@ class LoopAgent:
what = f"blocked by {', '.join(loop.blocked_by)}"
elif item.position == READY:
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
lines.append(f"#{loop.issue} {item.title}: {what}")
lines.append(f"{self._label(item)} {item.title}: {what}")
return "\n".join(lines)
# -- running (L1, L2) ------------------------------------------------------
@@ -709,20 +804,46 @@ class LoopAgent:
return
self._run(ready[0])
def _keep(self, project: str) -> tuple[str, ...]:
"""Branch prefixes of a project's open items: parked or not, they
are never set aside."""
return tuple(
f"{i.id}-" for i in self._store.work_items("open") if i.scope == project
)
def _park_elsewhere(self, here: str) -> None:
"""L1: switching to another project's item parks the one on the
project worked on last, so nothing lives only on its build VM. After
a boot nobody knows which that was, so every other is parked."""
last = self._last_project
others = [p for p in self._projects.values() if p.name != here]
for other in others if last is None else [self._projects[last]]:
if other.name == here or other.milestones is None:
continue
try:
other.milestones.park(keep=self._keep(other.name))
except Exception as exc: # noqa: BLE001 -- reported; the run goes on
self._oplog.emit(
"loop.park_failed", cause=repr(exc), project=other.name
)
self._last_project = here
def _run(self, item: WorkItem) -> None:
loop = loop_of(item)
project = self._at(item)
milestones = project.milestones
self._store.set_active(item.id, cause="ready")
if self._milestones is not None:
# Branches of open items, parked or not, are never set aside.
keep = tuple(f"{i.id}-" for i in self._store.work_items("open"))
self._park_elsewhere(project.name)
if milestones is not None:
keep = self._keep(project.name)
try:
if not loop.prepared:
# D3: a fresh workspace when a work item starts.
notes = self._milestones.prepare(f"{item.id}-work", keep=keep)
notes = milestones.prepare(f"{item.id}-work", keep=keep)
else:
# D4: back to its branch, parking whoever was there.
notes = []
self._milestones.resume(
milestones.resume(
loop.branch or f"{item.id}-work", keep=keep, mine=f"{item.id}-"
)
except Exception as exc: # noqa: BLE001 -- the item waits, not the loop
@@ -755,12 +876,13 @@ class LoopAgent:
]
),
]
workspace = project.workspace
system = SYSTEM.format(
repo=self._repo,
repo=project.repo,
issue=loop.issue,
title=item.title,
labels="".join(f"{LABELS[x]}\n\n" for x in loop.labels if x in LABELS),
workspace=self._workspace.describe() if self._workspace else "",
workspace=workspace.describe() if workspace else "",
)
self._active_thread = loop.thread
self._running = True
@@ -769,8 +891,9 @@ class LoopAgent:
self._store.transcript(item.id, "driver"),
system=system,
tools=tools,
workspace=self._workspace,
workspace=workspace,
handlers={"start_milestone": lambda args: self._start(item.id, args)},
checks={"restate": _restatement_problem},
)
finally:
self._running = False
@@ -805,12 +928,12 @@ class LoopAgent:
)
elif outcome.kind == "wait" and outcome.tool == "ask_human":
question = str(args.get("question", "")).strip()
self._answer_where_asked(loop, question)
self._answer_where_asked(item, loop, question)
self._wait(item, replace(loop, wait=ANSWER, note=question), None)
elif outcome.kind == "wait" and outcome.tool == "wait_for_review":
note = str(args.get("note", "")).strip() or f"waiting on PR #{loop.pr}"
if loop.pr:
self._answer_where_asked(loop, note)
self._answer_where_asked(item, loop, note)
self._wait(item, replace(loop, wait=MERGE, note=f"PR #{loop.pr}"), None)
else:
say = f"{note} (there's no open PR, so I need you to tell me what next)"
@@ -855,11 +978,12 @@ class LoopAgent:
item = self._store.work_item(item_id)
assert item is not None
loop = loop_of(item)
project = self._at(item)
if loop.pr:
# D4: the next milestone waits for the merge, read from Gitea now,
# not from the last watch round.
try:
pr = self._gitea.pull_request(self._repo, loop.pr)
pr = self._gitea.pull_request(project.repo, loop.pr)
except GiteaError as exc:
return f"couldn't check PR #{loop.pr}: {exc}; try again", False
if pr.state == "open":
@@ -875,12 +999,12 @@ class LoopAgent:
if loop.branch:
busy = f"milestone {loop.milestone} is still open; finish it first"
return busy, False
if self._milestones is None:
if project.milestones is None:
return "milestones aren't available here", False
number = loop.milestone + 1
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
try:
self._milestones.start(branch)
project.milestones.start(branch)
except GitError as exc:
return f"couldn't start the milestone: {exc}", False
name = str(args.get("name", "")).strip()
@@ -894,7 +1018,7 @@ class LoopAgent:
)
return f"started milestone {number} on branch {branch}", True
def _answer_where_asked(self, loop: Loop, text: str) -> None:
def _answer_where_asked(self, item: WorkItem, loop: Loop, text: str) -> None:
"""D5: answer where the operator asked. A PR question is answered on
the PR, quoting the line it was about (an inline thread can't be
replied to through the API), with a pointer in the Matrix thread."""
@@ -905,7 +1029,7 @@ class LoopAgent:
quoted += [f"> {line}" for line in loop.asked_text.splitlines()]
body = "\n".join([*quoted, "", text])
try:
self._gitea.post_comment(self._repo, loop.asked_on_pr, body)
self._gitea.post_comment(self._at(item).repo, loop.asked_on_pr, body)
except GiteaError:
self._transport.post(text, thread=loop.thread) # the PR is unreachable
return
@@ -924,12 +1048,14 @@ class LoopAgent:
final: bool = True,
) -> None:
"""finish_milestone: publish the milestone, or push a review fix."""
if self._milestones is None:
project = self._at(item)
milestones = project.milestones
if milestones is None:
note = "milestones aren't available here"
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
return
if loop.pr:
fixed = self._milestones.push_fix(
fixed = milestones.push_fix(
item_id=item.id,
branch=loop.branch,
pr=loop.pr,
@@ -938,7 +1064,7 @@ class LoopAgent:
)
self._fact(item, f"review fix: {fixed.note}")
if fixed.kind in ("pushed", "nothing"):
self._gitea_comment(loop.pr, fixed.note)
self._gitea_comment(project.repo, loop.pr, fixed.note)
self._wait(
item,
replace(loop, wait=MERGE, note=f"PR #{loop.pr}"),
@@ -971,13 +1097,13 @@ class LoopAgent:
number = loop.milestone + 1
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
try:
self._milestones.start(branch)
milestones.start(branch)
except GitError as exc:
note = f"I couldn't start milestone {number}: {exc}"
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
return
loop = replace(loop, milestone=number, branch=branch)
published = self._milestones.publish(
published = milestones.publish(
item_id=item.id,
branch=loop.branch,
title=title or f"{item.title} (milestone {loop.milestone})",
@@ -1018,9 +1144,9 @@ class LoopAgent:
f"Milestone {loop.milestone}: {published.note}. What next?",
)
def _gitea_comment(self, pr: int, text: str) -> None:
def _gitea_comment(self, repo: str, pr: int, text: str) -> None:
try:
self._gitea.post_comment(self._repo, pr, text)
self._gitea.post_comment(repo, pr, text)
except GiteaError:
pass # courtesy only; the pushed commit is the answer
@@ -1031,26 +1157,27 @@ class LoopAgent:
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
self._fact(item, f"work item {outcome}: {note}")
project = self._at(item)
if loop.pr and outcome == "abandoned":
# L2: abandoning closes the item's open PR with a note.
try:
self._gitea.post_comment(self._repo, loop.pr, f"abandoned: {note}")
self._gitea.close_pull_request(self._repo, loop.pr)
self._gitea.post_comment(project.repo, loop.pr, f"abandoned: {note}")
self._gitea.close_pull_request(project.repo, loop.pr)
except GiteaError as exc:
self._transport.post(
f"could not close PR #{loop.pr}: {exc}", thread=loop.thread
)
save(self._store, item.id, CLOSED, loop, cause=outcome)
self._store.close_work_item(item.id, outcome=outcome, cause=note)
if self._milestones is not None:
if project.milestones is not None:
try:
self._milestones.drop_parked(f"{item.id}-")
project.milestones.drop_parked(f"{item.id}-")
except GitError:
pass # a leftover side branch; nothing depends on it
if loop.issue:
try:
self._gitea.post_comment(self._repo, loop.issue, f"{outcome}: {note}")
self._gitea.close_issue(self._repo, loop.issue)
self._gitea.post_comment(project.repo, loop.issue, f"{outcome}: {note}")
self._gitea.close_issue(project.repo, loop.issue)
except GiteaError as exc:
self._transport.post(
f"closed here, but could not close #{loop.issue}: {exc}",
+35 -24
View File
@@ -16,7 +16,7 @@ from nisse_dev.agent import (
from nisse_dev.cancel import Cancel
from nisse_dev.config import AgentConfig
from nisse_dev.gitea import GiteaClient
from nisse_dev.loop.agent import LoopAgent
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.milestones import Milestones
from nisse_dev.loop.reader import GoAheadReader
@@ -41,7 +41,8 @@ class RunningLoop:
def start(self) -> None:
self.loop.start()
self.transport.post(
f"nisse-dev up as {self.account} (new loop) on {self.config.gitea_repo}; "
f"nisse-dev up as {self.account} (new loop) on "
f"{', '.join(p.repo for p in self.config.projects)}; "
f"{self.loop_status()}"
)
@@ -75,16 +76,37 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
catalog = discover_models(config, oplog)
gitea = GiteaClient(config.gitea_url, config.gitea_token)
account = gitea.whoami()
host = build_host(config)
credential = push_credential(config)
remote = remote_url(gitea, config.gitea_repo, credential)
git = prepare_workspace(host, config, account, remote, credential)
workspace = Workspace(
host,
config.workspace,
timeout_s=config.tuning.tools.command_timeout_s,
max_output_chars=config.tuning.tools.max_output_chars,
)
# D8: each project its own build VM, clone, push credential and milestones.
projects = []
for entry in config.projects:
host = build_host(config, entry)
credential = push_credential(config, entry)
remote = remote_url(gitea, entry.repo, credential)
git = prepare_workspace(host, config, account, remote, credential, entry)
projects.append(
Project(
name=entry.name,
repo=entry.repo,
labels=entry.labels,
workspace=Workspace(
host,
entry.workspace,
timeout_s=config.tuning.tools.command_timeout_s,
max_output_chars=config.tuning.tools.max_output_chars,
# D7: the secrets its commands may see, redacted on return.
secrets=entry.command_secrets(),
),
milestones=Milestones(
store,
git,
gitea,
repo=entry.repo,
remote=remote,
credential=credential,
base=entry.base_branch,
),
)
)
normalized, _passthrough = make_llm_clients(config, catalog)
cancel = Cancel()
# The driver is the planner's model for now (M2.6 brings the driver role).
@@ -99,13 +121,11 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
store,
transport,
gitea,
repo=config.gitea_repo,
project=config.project,
projects=projects,
account=account,
operators=config.operators,
gitea_operators=config.gitea_operators,
driver=driver,
workspace=workspace,
worker=worker,
cancel=cancel,
oplog=oplog,
@@ -114,15 +134,6 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
# classifier role arrives (M2.6).
reader=GoAheadReader(normalized, config.roles.resolve("planner")),
bug_grace_s=config.tuning.loop.bug_grace_s,
milestones=Milestones(
store,
git,
gitea,
repo=config.gitea_repo,
remote=remote,
credential=credential,
base=config.base_branch,
),
)
return RunningLoop(
config=config,
+18 -1
View File
@@ -58,10 +58,14 @@ class Driver:
workspace: Workspace | None,
handlers: Mapping[str, Callable[[dict[str, Any]], tuple[str, bool]]]
| None = None,
checks: Mapping[str, Callable[[dict[str, Any]], str | None]] | None = None,
) -> RunOutcome:
"""``handlers`` answer control tools that don't end the run (e.g.
start_milestone): each returns a result and whether it succeeded."""
start_milestone): each returns a result and whether it succeeded.
``checks`` vet a waiting tool's arguments: a problem is the call's
failed result and the run goes on, so the model puts it right."""
handlers = handlers or {}
checks = checks or {}
offered = names(tools)
nudged = False
for turn in range(self._max_turns):
@@ -89,6 +93,19 @@ class Driver:
if self._cancel.requested:
skip_calls(transcript, calls[index:])
return RunOutcome("stopped", note=f"during turn {turn + 1}")
problem = (
checks[call.tool](dict(call.arguments))
if call.tool in checks and call.tool in offered
else None
)
if problem is not None:
transcript.append(
ToolResult(call_id=call.call_id, result=problem, success=False)
)
skip_calls(
transcript, calls[index + 1 :], "not run: fix the call above"
)
break
if call.tool in WAITING and call.tool in offered:
transcript.append(
ToolResult(
+6 -4
View File
@@ -82,10 +82,12 @@ def item_for_thread(store: StateStore, thread: str) -> WorkItem | None:
return None
def item_for_issue(store: StateStore, issue: int) -> WorkItem | None:
"""Any item, open or closed, recorded by this issue: intake never takes
an issue twice (D1)."""
def item_for_issue(
store: StateStore, issue: int, project: str | None = None
) -> WorkItem | None:
"""Any item, open or closed, recorded by this issue (in ``project``,
when given): intake never takes an issue twice (D1)."""
for item in store.work_items():
if loop_of(item).issue == issue:
if loop_of(item).issue == issue and project in (None, item.scope):
return item
return None