"""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 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 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 build_loop_agent(config: AgentConfig) -> RunningLoop: 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 = [] 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). 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")), 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(), )