New projects from conversation (M2.5, PR B)
- 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
This commit is contained in:
1 parent
9a86ccb2a4
commit
ad2d090143
13 files changed
+723
-54
No files matched your search
@@ -2,6 +2,7 @@
|
||||
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 (
|
||||
@@ -14,7 +15,8 @@ from nisse_dev.agent import (
|
||||
remote_url,
|
||||
)
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.config import AgentConfig
|
||||
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
|
||||
@@ -64,7 +66,48 @@ class RunningLoop:
|
||||
self._stopped.wait()
|
||||
|
||||
|
||||
def build_loop_agent(config: AgentConfig) -> RunningLoop:
|
||||
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(
|
||||
@@ -77,36 +120,10 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
|
||||
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,
|
||||
),
|
||||
)
|
||||
)
|
||||
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).
|
||||
@@ -133,6 +150,11 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
|
||||
# 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,
|
||||
)
|
||||
@@ -146,3 +168,12 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
|
||||
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
|
||||
Reference in new issue
Block a user