CI / checks (push) Skipped
CI / checks (pull_request) Successful in 4m56s
- propose_project(name, purpose, decisions) in a conversation goes up for a go-ahead (checked: a slug, not an existing project, no trailing question). - On go, the provisioning checklist (D1): a repository with a README, protection on its default branch, the agent as a collaborator, a build VM, a deploy key, and the project entry as YAML with <...> to fill in. - On "done", the agent reads its configuration again and adds the new project live (build_loop_agent(load=...); only additions), then checks what was provided (D4, verified, not assumed): permissions and protection as it sees them, then on a throwaway branch with an empty commit, a branch push must succeed and a push to the default branch and merging its own PR must be refused. The check's PR is closed and its branch deleted. Failures are said per item; nothing starts. - Checked: the tracking issue (the distilled decisions, carrying the new item's ID), and the project's item, agreed and labelled `design`, whose first milestone is ARCHITECTURE.md and the build plan. A conversation waiting on provisioning doesn't close as idle. - The walk replays exactly from its seed (item IDs too), and event IDs stay unique across simulated restarts, as a server's are. - The test config holds output to 8192 tokens, under the test key's per-request cost cap. Tests: new-project unit tests (checklist and its YAML parse as config, done before the entry, what isn't set up, a checked project's record and first PR, an existing name), protection enforced by a hook on the fake forge, the walk proposing projects and saying done, and an e2e new project in a repository the test operator provisions from the agent's checklist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
180 lines
5.6 KiB
Python
180 lines
5.6 KiB
Python
"""Assembling the new loop (M2.1): the same services as the old agent, with
|
|
LoopAgent in place of the Orchestrator. Chosen with `loop: new` in config."""
|
|
|
|
import threading
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
|
|
from nisse_dev.agent import (
|
|
SHUTDOWN_WAIT_S,
|
|
build_host,
|
|
discover_models,
|
|
make_llm_clients,
|
|
prepare_workspace,
|
|
push_credential,
|
|
remote_url,
|
|
)
|
|
from nisse_dev.cancel import Cancel
|
|
from nisse_dev.config import AgentConfig, ConfigError
|
|
from nisse_dev.config import Project as ProjectEntry
|
|
from nisse_dev.gitea import GiteaClient
|
|
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
|
|
from nisse_dev.matrix import MatrixTransport
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.tools import Workspace
|
|
from nisse_dev.worker import SerialWorker
|
|
|
|
|
|
@dataclass
|
|
class RunningLoop:
|
|
config: AgentConfig
|
|
account: str
|
|
loop: LoopAgent
|
|
store: StateStore
|
|
transport: MatrixTransport
|
|
gitea: GiteaClient
|
|
worker: SerialWorker
|
|
_stopped: threading.Event
|
|
|
|
def start(self) -> None:
|
|
self.loop.start()
|
|
self.transport.post(
|
|
f"nisse-dev up as {self.account} (new loop) on "
|
|
f"{', '.join(p.repo for p in self.config.projects)}; "
|
|
f"{self.loop_status()}"
|
|
)
|
|
|
|
def loop_status(self) -> str:
|
|
return self.worker.call(self.loop.status)
|
|
|
|
def stop(self) -> None:
|
|
self.loop.stop()
|
|
self.transport.close()
|
|
self.gitea.close()
|
|
try:
|
|
self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S)
|
|
except TimeoutError:
|
|
pass # still working; L8 covers exiting mid-work
|
|
self.worker.stop(timeout=SHUTDOWN_WAIT_S)
|
|
self._stopped.set()
|
|
|
|
def wait(self) -> None:
|
|
self._stopped.wait()
|
|
|
|
|
|
def project_runtime(
|
|
config: AgentConfig,
|
|
entry: ProjectEntry,
|
|
store: StateStore,
|
|
gitea: GiteaClient,
|
|
account: str,
|
|
) -> Project:
|
|
"""One project's part of the loop: its clone on its build VM, workspace
|
|
(with the secrets its commands may see, redacted on return), push
|
|
credential and milestones."""
|
|
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)
|
|
return 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,
|
|
secrets=entry.command_secrets(),
|
|
),
|
|
milestones=Milestones(
|
|
store,
|
|
git,
|
|
gitea,
|
|
repo=entry.repo,
|
|
remote=remote,
|
|
credential=credential,
|
|
base=entry.base_branch,
|
|
),
|
|
)
|
|
|
|
|
|
def build_loop_agent(
|
|
config: AgentConfig, load: Callable[[], AgentConfig] | None = None
|
|
) -> RunningLoop:
|
|
"""``load`` reads the configuration again, for a new project's entry
|
|
(D8); by default from the file this one came from."""
|
|
oplog = OpLog("nisse-dev")
|
|
worker = SerialWorker(
|
|
on_error=lambda exc: oplog.emit(
|
|
"worker.job_failed", cause="unhandled exception", error=repr(exc)
|
|
)
|
|
)
|
|
worker.start()
|
|
store = worker.call(lambda: StateStore(config.state_db, oplog=oplog))
|
|
catalog = discover_models(config, oplog)
|
|
gitea = GiteaClient(config.gitea_url, config.gitea_token)
|
|
account = gitea.whoami()
|
|
# D8: each project its own build VM, clone, push credential and milestones.
|
|
projects = [
|
|
project_runtime(config, entry, store, gitea, account)
|
|
for entry in config.projects
|
|
]
|
|
normalized, _passthrough = make_llm_clients(config, catalog)
|
|
cancel = Cancel()
|
|
# The driver is the planner's model for now (M2.6 brings the driver role).
|
|
driver = Driver(
|
|
normalized,
|
|
config.roles.resolve("planner"),
|
|
max_turns=config.tuning.executor.max_turns,
|
|
cancel=cancel,
|
|
)
|
|
transport = MatrixTransport(config.matrix)
|
|
loop = LoopAgent(
|
|
store,
|
|
transport,
|
|
gitea,
|
|
projects=projects,
|
|
account=account,
|
|
operators=config.operators,
|
|
gitea_operators=config.gitea_operators,
|
|
driver=driver,
|
|
worker=worker,
|
|
cancel=cancel,
|
|
oplog=oplog,
|
|
roles=config.roles.pinned(),
|
|
# The driver's model reads plain-language go-aheads until the
|
|
# classifier role arrives (M2.6).
|
|
reader=GoAheadReader(normalized, config.roles.resolve("planner")),
|
|
reload=lambda known: [
|
|
project_runtime(config, entry, store, gitea, account)
|
|
for entry in (load or _from_file(config))().projects
|
|
if entry.name not in known
|
|
],
|
|
bug_grace_s=config.tuning.loop.bug_grace_s,
|
|
general_idle_s=config.tuning.loop.general_idle_s,
|
|
)
|
|
return RunningLoop(
|
|
config=config,
|
|
account=account,
|
|
loop=loop,
|
|
store=store,
|
|
transport=transport,
|
|
gitea=gitea,
|
|
worker=worker,
|
|
_stopped=threading.Event(),
|
|
)
|
|
|
|
|
|
def _from_file(config: AgentConfig) -> Callable[[], AgentConfig]:
|
|
def load() -> AgentConfig:
|
|
if config.config_file is None:
|
|
raise ConfigError("this agent wasn't started from a config file")
|
|
return AgentConfig.from_file(config.config_file)
|
|
|
|
return load
|