Files
nisse/nisse-dev/src/nisse_dev/loop/build.py
T
dongarandClaude Opus 5.5 3f54896f65
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m14s
Conversations on the new loop (M2.5, PR A)
- A top-level message that isn't a command is a conversation: an item in
  the `general` scope (L1), threaded under the operator's message, with
  `reply`, `ask_human` and `propose_work` and no workspace or execution.
  `general` is reserved as a project name.
- propose_work(project, title, summary) goes up as a revision for a
  go-ahead (or a plain yes). On go, under a file_issue intent (L8), the
  record issue is opened in that project, assigned to the agent and
  carrying the new item's ID; its item starts agreed, with the summary as
  its brief. Intake skips any issue carrying an ID (D1); boot adopts a
  filed issue or puts the proposal up again.
- A conversation closes after tuning.loop.general_idle_s (default a day);
  a reply in its thread then starts a new conversation that continues it.

Tests: conversation unit tests (threading, filing, the marker, an unknown
project, changes, idle close and continuation, a plain yes, both crash
cases, commands at top level); the walk with conversations, proposals and
idle time, and records_have_one_item / conversations_do_no_work; an e2e
conversation that becomes filed work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-06 04:26:40 +00:00

149 lines
4.7 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 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(),
)