Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
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:
1 parent
4ecff19933
commit
064d9bf554
17 files changed
+1139
-315
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user