Merge pull request 'New projects from conversation (M2.5, PR B)' (#108) from new-projects into main
CI / e2e (push) Skipped
CI / checks (push) Successful in 5m6s

This commit was merged in pull request #108.
This commit is contained in:
dev-agent committed 2026-10-07 16:25:41 +00:00
commit 04c352b30c
15 files changed
+823 -60

No files matched your search

+15 -3
View File
@@ -192,7 +192,7 @@ between milestones (D2, D4). End-to-end: a two-milestone feature.
- Deferred: a milestone with no changes that wasn't expected to change code is - Deferred: a milestone with no changes that wasn't expected to change code is
still raised with the operator (D4). still raised with the operator (D4).
**M2.5 Conversations and new projects.** *Needs discussion;* the design was agreed **M2.5 Conversations and new projects.** **Done** (#106 and this PR). *Needs discussion;* the design was agreed
in this PR (`general` scope, L1, L5; the new-project sequence, D1). Work started in in this PR (`general` scope, L1, L5; the new-project sequence, D1). Work started in
conversation, record issues carrying their item's ID, the new-project flow and its conversation, record issues carrying their item's ID, the new-project flow and its
project entry (D1, D8, #29). After M1.4. End-to-end: a small new project, in a project entry (D1, D8, #29). After M1.4. End-to-end: a small new project, in a
@@ -207,8 +207,20 @@ deletes afterwards.
settles a filing cut off by a crash. settles a filing cut off by a crash.
- A conversation closes after `tuning.loop.general_idle_s` without a message; a - A conversation closes after `tuning.loop.general_idle_s` without a message; a
reply in its thread then starts a new one that continues it. reply in its thread then starts a new one that continues it.
- **PR B, new projects:** the proposal, the checklist, reload-and-verify, and the - **PR B, new projects — done (this PR).**
tracking issue; the e2e with a per-run repository. - `propose_project(name, purpose, decisions)` goes up for a go-ahead.
- On go, the checklist: repository, protection, collaborator, build VM, deploy
key, and the project entry as YAML to fill in.
- On "done", the agent reads its configuration again and adds the new project
live (only additions). It then checks the access: the repository's
permissions and protection as the agent sees them, then in practice on a
throwaway branch with an empty commit (a branch push must succeed, a push to
the default branch and merging its own PR must be refused); the branch and PR
are removed. Anything that fails is said, and nothing starts.
- When all is well: the tracking issue with the distilled decisions, 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.
**M2.6 Roles and capability profiles.** *Pre-approved.* `driver`, `helper`, **M2.6 Roles and capability profiles.** *Pre-approved.* `driver`, `helper`,
`expert`, `classifier` (L9); escalation triggers; delegation and `ask_expert` on the `expert`, `classifier` (L9); escalation triggers; delegation and `ask_expert` on the
+7
View File
@@ -255,6 +255,13 @@ class GitRepo:
self._git("commit", "-q", "-m", message) self._git("commit", "-q", "-m", message)
return self.head_sha() return self.head_sha()
def changed_paths(self, since: str) -> list[str]:
"""Every path the work changes relative to ``since``, committed or
not, new files included; build artifacts aren't work (#80)."""
tracked = self._git("diff", "--name-only", since).splitlines()
new = self._git("ls-files", "--others", "--exclude-standard").splitlines()
return sorted({p for p in [*tracked, *new] if p})
def left_out(self) -> list[str]: def left_out(self) -> list[str]:
"""Changes not in the last commit: build artifacts the harness left """Changes not in the last commit: build artifacts the harness left
out, to be reported rather than silently dropped (D4).""" out, to be reported rather than silently dropped (D4)."""
+13
View File
@@ -261,6 +261,19 @@ class GiteaClient:
who = (event.get("user") or {}).get("login") who = (event.get("user") or {}).get("login")
return who return who
def repository(self, repo: str) -> dict[str, Any]:
"""The repository as this account sees it, permissions included."""
return dict(self._request("GET", f"/repos/{repo}"))
def branch(self, repo: str, name: str) -> dict[str, Any]:
"""A branch as this account sees it: protected, user_can_push."""
return dict(self._request("GET", f"/repos/{repo}/branches/{name}"))
def merge_pull_request(self, repo: str, index: int) -> None:
self._request(
"POST", f"/repos/{repo}/pulls/{index}/merge", json={"Do": "merge"}
)
def assign_issue(self, repo: str, index: int, assignee: str) -> None: def assign_issue(self, repo: str, index: int, assignee: str) -> None:
self._request( self._request(
"PATCH", f"/repos/{repo}/issues/{index}", json={"assignees": [assignee]} "PATCH", f"/repos/{repo}/issues/{index}", json={"assignees": [assignee]}
+213 -9
View File
@@ -36,6 +36,7 @@ from nisse_dev.loop.items import (
CLOSED, CLOSED,
GO_AHEAD, GO_AHEAD,
MERGE, MERGE,
PROVISION,
READY, READY,
RUNNING, RUNNING,
WAITING, WAITING,
@@ -51,6 +52,7 @@ from nisse_dev.loop.tools import (
ASK_HUMAN, ASK_HUMAN,
CLOSE_ITEM, CLOSE_ITEM,
FINISH_MILESTONE, FINISH_MILESTONE,
PROPOSE_PROJECT,
PROPOSE_WORK, PROPOSE_WORK,
REPLY, REPLY,
RESTATE, RESTATE,
@@ -133,9 +135,10 @@ is the new issue's body and the work's whole brief.
{projects} {projects}
Setting up a new project from here isn't available yet: if that's where the If it becomes a new project, use propose_project: its name, what it's for,
conversation goes, say so, and that the operator can create the repository and the decisions setting it up depends on. Once agreed, you'll be asked for
and its project entry.""" what it needs set up; when the operator has it ready and it checks out, its
architecture and build plan are the first piece of work."""
# What an issue's labels add to the driver's instructions (D1). # What an issue's labels add to the driver's instructions (D1).
LABELS = { LABELS = {
@@ -147,9 +150,11 @@ LABELS = {
"discuss": "This issue is labelled discuss: talk it through with the " "discuss": "This issue is labelled discuss: talk it through with the "
"operator (ask_human) before restating, and restate only once you agree " "operator (ask_human) before restating, and restate only once you agree "
"on what is wanted.", "on what is wanted.",
"design": "This issue is labelled design: agree the design with the " "design": "This issue is labelled design: the design comes before any "
"operator before any code. Your restatement puts the design to them; " "code. Your first milestone is documents only: ARCHITECTURE.md and a "
"code waits for their go-ahead on it.", "build plan under docs/plans/, in a PR of their own (if you restate, put "
"the design to the operator). Code starts in a later milestone, after that "
"PR has merged; the harness refuses a design milestone with anything else.",
} }
@@ -204,6 +209,9 @@ class LoopAgent:
reader: GoAheadReader | None = None, reader: GoAheadReader | None = None,
bug_grace_s: float = BUG_GRACE_S, bug_grace_s: float = BUG_GRACE_S,
general_idle_s: float = GENERAL_IDLE_S, general_idle_s: float = GENERAL_IDLE_S,
# A new project's entry, once the operator has added it (D1, D8):
# the projects configured now that this agent doesn't know yet.
reload: Callable[[set[str]], Sequence[Project]] | None = None,
clock: Callable[[], float] = time.time, clock: Callable[[], float] = time.time,
intake_interval_s: float = INTAKE_INTERVAL_S, intake_interval_s: float = INTAKE_INTERVAL_S,
) -> None: ) -> None:
@@ -224,6 +232,7 @@ class LoopAgent:
self._reader = reader self._reader = reader
self._grace = bug_grace_s self._grace = bug_grace_s
self._idle = general_idle_s self._idle = general_idle_s
self._reload = reload
self._clock = clock self._clock = clock
self._interval = intake_interval_s self._interval = intake_interval_s
# thread root -> revision on screen there, read on the transport's # thread root -> revision on screen there, read on the transport's
@@ -512,6 +521,9 @@ class LoopAgent:
if pr.merged: if pr.merged:
self._fact(item, f"PR #{loop.pr} merged") self._fact(item, f"PR #{loop.pr} merged")
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
if "design" in loop.labels and not loop.design_merged:
self._fact(item, "the design has merged: code may follow")
loop = replace(loop, design_merged=True)
elif pr.state != "open": elif pr.state != "open":
self._fact(item, f"PR #{loop.pr} was closed without merging") self._fact(item, f"PR #{loop.pr} was closed without merging")
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
@@ -566,6 +578,7 @@ class LoopAgent:
if ( if (
item.scope == GENERAL item.scope == GENERAL
and item.position == WAITING and item.position == WAITING
and not loop.new_project # setting one up can take days
and loop.idle_since and loop.idle_since
and now - loop.idle_since >= self._idle and now - loop.idle_since >= self._idle
): ):
@@ -761,6 +774,8 @@ class LoopAgent:
item = self._store.work_item(item.id) or item item = self._store.work_item(item.id) or item
if command == "abandon": if command == "abandon":
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}") self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
elif command == "done" and loop.new_project:
self._provisioned(item, loop)
elif command == "stop": elif command == "stop":
# Stopping a run took effect on arrival; a stop is never an answer. # Stopping a run took effect on arrival; a stop is never an answer.
if not loop.note.startswith("stopped ("): if not loop.note.startswith("stopped ("):
@@ -850,6 +865,9 @@ class LoopAgent:
def _go(self, item: WorkItem, loop: Loop, by: str) -> None: def _go(self, item: WorkItem, loop: Loop, by: str) -> None:
self._fact(item, f"go-ahead on rev-{loop.rev} from {by}") self._fact(item, f"go-ahead on rev-{loop.rev} from {by}")
if item.scope == GENERAL and loop.proposal_kind == "project":
self._checklist(item, loop)
return
if item.scope == GENERAL: if item.scope == GENERAL:
self._file_work(item, loop) self._file_work(item, loop)
return return
@@ -949,6 +967,117 @@ class LoopAgent:
"We can keep talking here.", "We can keep talking here.",
) )
# -- new projects (D1, D8) --------------------------------------------------
def _checklist(self, item: WorkItem, loop: Loop) -> None:
"""An agreed new project: what it needs, for the operator to provide
(later nisse-ops, O6), with its project entry to add."""
name = loop.proposal_project
entry = (
"projects:\n"
f" {name}:\n"
f" repo: <owner>/{name}\n"
" base_branch: main\n"
f" workspace: /root/work/{name}\n"
" build_host:\n"
" address: <the build VM>\n"
" key: <my SSH key for it>\n"
" push_key: <the deploy key's private half>"
)
self._fact(item, f"provisioning checklist posted for {name}")
self._wait(
item,
replace(
loop,
wait=PROVISION,
note=f"{name}'s setup: say done when it's ready",
new_project=name,
idle_since=self._clock(),
),
f"To set up `{name}`, I need:\n"
f"1. A repository for it, e.g. <owner>/{name}, with a README so its "
"default branch exists.\n"
"2. Protection on the default branch: none of my pushes, and an "
"approval to merge.\n"
f"3. Me ({self._account}) as a collaborator with write access.\n"
"4. A build VM, with the toolchain from the decisions above.\n"
"5. A deploy key with write access, its private half where I run.\n"
"6. This entry in my configuration, with the <...> parts filled "
f"in:\n\n```yaml\n{entry}\n```\n\n"
"Say done when it's ready, and I'll check each of them.",
)
def _provisioned(self, item: WorkItem, loop: Loop) -> None:
""" "done": the entry is read again and the new project checked
(D4: verified, not assumed). What fails is said, per item; when all
is well the tracking issue is opened, and the project's item starts
with its architecture."""
name = loop.new_project
try:
added = list(self._reload(set(self._projects))) if self._reload else []
except Exception as exc: # noqa: BLE001 -- said; the operator fixes it
self._transport.post(
f"I couldn't read the new entry: {exc}. Fix it and say done again.",
thread=loop.thread,
)
return
project = next((p for p in added if p.name == name), None)
if project is None:
self._transport.post(
f"I don't see `{name}` in my configuration yet (item 6). Add its "
"entry and say done again.",
thread=loop.thread,
)
return
failures = (
project.milestones.verify_access(self._account)
if project.milestones is not None
else ["the project has no workspace"]
)
if failures:
self._fact(item, f"{name} checked: " + "; ".join(failures))
self._transport.post(
"Not ready yet: " + "; ".join(failures) + ". Say done again when "
"they're fixed.",
thread=loop.thread,
)
return
self._projects[name] = project
self._fact(item, f"{name} set up and checked: {project.repo}")
brief = self._latest_proposal(item)
new_id = f"{name}-c{uuid.uuid4().hex[:6]}"
title = f"Implement the build plan for {name}"
try:
number = self._gitea.create_issue(
project.repo,
title,
f"{brief}\n\nFrom a conversation with the operator, agreed there "
f"as rev-{loop.rev}. First: ARCHITECTURE.md and the build plan, "
"in a PR of documents only; code after it has merged."
f"\n\n<!-- nisse-item: {new_id} -->",
)
self._gitea.assign_issue(project.repo, number, self._account)
except GiteaError as exc:
self._transport.post(
f"{name} checks out, but I couldn't open its tracking issue: "
f"{exc}. Say done to try again.",
thread=loop.thread,
)
return
# D1: the architecture is agreed in its PR, as `design` steers.
design = replace(
project, labels=tuple(dict.fromkeys(("design", *project.labels)))
)
self._start_filed(item, design, new_id, number, title, brief)
loop = loop_of(self._store.work_item(item.id) or item)
save(
self._store,
item.id,
WAITING,
replace(loop, new_project="", proposal_kind=""),
cause=f"{name} provisioned",
)
def _latest_proposal(self, item: WorkItem) -> str: def _latest_proposal(self, item: WorkItem) -> str:
proposals = [ proposals = [
e.event.plan e.event.plan
@@ -1088,6 +1217,32 @@ class LoopAgent:
) )
self._last_project = here self._last_project = here
def _design_problem(self, item_id: str) -> str | None:
"""D1, D2: a `design` item agrees its design before any code. Until
a PR of it has merged, a milestone holds documents only."""
item = self._store.work_item(item_id)
if item is None:
return None
loop = loop_of(item)
milestones = self._at(item).milestones
if "design" not in loop.labels or loop.design_merged or milestones is None:
return None
try:
changed = milestones.changed()
except GitError:
return None # publishing reports what's wrong with the workspace
code = [
p for p in changed if p != "ARCHITECTURE.md" and not p.startswith("docs/")
]
if not code:
return None
return (
"not published: this milestone is the design, so it holds documents "
"only (ARCHITECTURE.md, docs/); code starts in a later milestone, "
"after this one's PR has merged. Take these out of it (they can "
"wait in the plan): " + ", ".join(code)
)
def _converse_run(self, item: WorkItem) -> None: def _converse_run(self, item: WorkItem) -> None:
"""A conversation's turn (L1, L5): it talks, and may propose work on """A conversation's turn (L1, L5): it talks, and may propose work on
a project. No workspace, nothing to run.""" a project. No workspace, nothing to run."""
@@ -1102,15 +1257,30 @@ class LoopAgent:
outcome = self._driver.run( outcome = self._driver.run(
self._store.transcript(item.id, "driver"), self._store.transcript(item.id, "driver"),
system=system, system=system,
tools=[REPLY, ASK_HUMAN, PROPOSE_WORK], tools=[REPLY, ASK_HUMAN, PROPOSE_WORK, PROPOSE_PROJECT],
workspace=None, workspace=None,
checks={"propose_work": self._proposal_problem}, checks={
"propose_work": self._proposal_problem,
"propose_project": self._project_problem,
},
) )
finally: finally:
self._running = False self._running = False
self._cancel.clear() self._cancel.clear()
self._settle(item.id, outcome) self._settle(item.id, outcome)
def _project_problem(self, args: dict[str, object]) -> str | None:
name = str(args.get("name", "")).strip()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) or name == GENERAL:
return "a project's name is lowercase letters, digits and -, not general"
if name in self._projects:
return f"{name} is a project already: propose_work for work on it"
if not str(args.get("purpose", "")).strip():
return "say what the project is for"
return _restatement_problem(
{"text": f"{args.get('purpose', '')}\n{args.get('decisions', '')}"}
)
def _proposal_problem(self, args: dict[str, object]) -> str | None: def _proposal_problem(self, args: dict[str, object]) -> str | None:
project = str(args.get("project", "")).strip() project = str(args.get("project", "")).strip()
if project not in self._projects: if project not in self._projects:
@@ -1191,7 +1361,10 @@ class LoopAgent:
tools=tools, tools=tools,
workspace=workspace, workspace=workspace,
handlers={"start_milestone": lambda args: self._start(item.id, args)}, handlers={"start_milestone": lambda args: self._start(item.id, args)},
checks={"restate": _restatement_problem}, checks={
"restate": _restatement_problem,
"finish_milestone": lambda args: self._design_problem(item.id),
},
) )
finally: finally:
self._running = False self._running = False
@@ -1233,6 +1406,7 @@ class LoopAgent:
None, None,
) )
elif outcome.kind == "wait" and outcome.tool == "propose_work": elif outcome.kind == "wait" and outcome.tool == "propose_work":
loop = replace(loop, proposal_kind="work")
project = self._projects[str(args.get("project", "")).strip()] project = self._projects[str(args.get("project", "")).strip()]
title = str(args.get("title", "")).strip() title = str(args.get("title", "")).strip()
summary = str(args.get("summary", "")).strip() summary = str(args.get("summary", "")).strip()
@@ -1261,6 +1435,36 @@ class LoopAgent:
), ),
None, None,
) )
elif outcome.kind == "wait" and outcome.tool == "propose_project":
name = str(args.get("name", "")).strip()
purpose = str(args.get("purpose", "")).strip()
decisions = str(args.get("decisions", "")).strip()
brief = f"{purpose}\n\nDecisions:\n{decisions}" if decisions else purpose
rev = loop.rev + 1
self._store.transcript(item.id, "driver").append(
PlanSubmitted(plan=brief, model=self._roles.get("planner", ""))
)
self._fact(item, f"new project proposed as rev-{rev}: {name}")
self._shown[loop.thread] = rev
self._transport.post(
f"rev-{rev}: a new project, `{name}`. {brief}\n\nSay go and I'll "
"list what it needs set up, or reply with changes.",
thread=loop.thread,
)
self._wait(
item,
replace(
loop,
rev=rev,
wait=GO_AHEAD,
note="",
proposal_project=name,
proposal_title="",
proposal_kind="project",
idle_since=self._clock(),
),
None,
)
elif outcome.kind == "wait" and outcome.tool == "ask_human": elif outcome.kind == "wait" and outcome.tool == "ask_human":
question = str(args.get("question", "")).strip() question = str(args.get("question", "")).strip()
self._answer_where_asked(item, loop, question) self._answer_where_asked(item, loop, question)
+63 -32
View File
@@ -2,6 +2,7 @@
LoopAgent in place of the Orchestrator. Chosen with `loop: new` in config.""" LoopAgent in place of the Orchestrator. Chosen with `loop: new` in config."""
import threading import threading
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from nisse_dev.agent import ( from nisse_dev.agent import (
@@ -14,7 +15,8 @@ from nisse_dev.agent import (
remote_url, remote_url,
) )
from nisse_dev.cancel import Cancel 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.gitea import GiteaClient
from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver from nisse_dev.loop.driver import Driver
@@ -64,7 +66,48 @@ class RunningLoop:
self._stopped.wait() 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") oplog = OpLog("nisse-dev")
worker = SerialWorker( worker = SerialWorker(
on_error=lambda exc: oplog.emit( 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) gitea = GiteaClient(config.gitea_url, config.gitea_token)
account = gitea.whoami() account = gitea.whoami()
# D8: each project its own build VM, clone, push credential and milestones. # D8: each project its own build VM, clone, push credential and milestones.
projects = [] projects = [
for entry in config.projects: project_runtime(config, entry, store, gitea, account)
host = build_host(config, entry) for entry in config.projects
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) normalized, _passthrough = make_llm_clients(config, catalog)
cancel = Cancel() cancel = Cancel()
# The driver is the planner's model for now (M2.6 brings the driver role). # 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 # The driver's model reads plain-language go-aheads until the
# classifier role arrives (M2.6). # classifier role arrives (M2.6).
reader=GoAheadReader(normalized, config.roles.resolve("planner")), 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, bug_grace_s=config.tuning.loop.bug_grace_s,
general_idle_s=config.tuning.loop.general_idle_s, general_idle_s=config.tuning.loop.general_idle_s,
) )
@@ -146,3 +168,12 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
worker=worker, worker=worker,
_stopped=threading.Event(), _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
+4
View File
@@ -21,6 +21,7 @@ GO_AHEAD = "go-ahead" # a go on the restatement up (L3, D1)
ANSWER = "answer" # an answer to a question ANSWER = "answer" # an answer to a question
MERGE = "merge" # the PR's review and merge (D4, D5) MERGE = "merge" # the PR's review and merge (D4, D5)
CHAT = "conversation" # a conversation waits for the operator to say more (L5) CHAT = "conversation" # a conversation waits for the operator to say more (L5)
PROVISION = "provisioning" # a new project waits for what it needs (D1, D8)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -52,6 +53,9 @@ class Loop:
became: tuple[str, ...] = () became: tuple[str, ...] = ()
proposal_project: str = "" proposal_project: str = ""
proposal_title: str = "" proposal_title: str = ""
proposal_kind: str = "" # "work" on a project, or a new "project"
new_project: str = "" # a new project being provisioned (D1)
design_merged: bool = False # a `design` item's design PR has merged (D1, D2)
# Where the operator last spoke (D5: answer where asked): "" for the # Where the operator last spoke (D5: answer where asked): "" for the
# Matrix thread, or the PR it came from, with the inline location and a # Matrix thread, or the PR it came from, with the inline location and a
# little of what they said, for quoting. # little of what they said, for quoting.
@@ -7,7 +7,9 @@ Publishing is idempotent under an intent (L8): an open PR for the branch is
adopted, and a PR the operator closed is an answer, never reopened. adopted, and a PR the operator closed is an answer, never reopened.
""" """
import contextlib
import time import time
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
@@ -159,6 +161,83 @@ class Milestones:
if git.remote_sha(self._remote, parked, credential=self._credential): if git.remote_sha(self._remote, parked, credential=self._credential):
git.delete_remote_branch(self._remote, parked, credential=self._credential) git.delete_remote_branch(self._remote, parked, credential=self._credential)
def changed(self) -> list[str]:
"""What the current milestone changes, from the default branch."""
return self._git.changed_paths(f"origin/{self._base}")
# -- a new project's checks (D1, D4) -----------------------------------------
def verify_access(self, account: str) -> list[str]:
"""What the operator provided, checked rather than assumed: the
agent may push a branch with its push credential, may not push to
the default branch, and may not merge its own PR without approval.
A throwaway branch with an empty commit carries the checks; its PR
is closed and the branch deleted. Returns what failed."""
git, base, failures = self._git, self._base, []
try:
repo = self._gitea.repository(self._repo)
if not (repo.get("permissions") or {}).get("push"):
failures.append(f"{account} isn't a collaborator with write access")
branch = self._gitea.branch(self._repo, base)
if not branch.get("protected") or branch.get("user_can_push"):
failures.append(f"{base} isn't protected against my pushes")
except GiteaError as exc:
return [f"I can't see the repository {self._repo}: {exc}"]
check = f"nisse-check-{uuid.uuid4().hex[:6]}"
try:
git.fetch(credential=self._credential)
git.reset_to(check, f"origin/{base}")
git._git("commit", "-q", "--allow-empty", "-m", "nisse-dev: access check")
except GitError as exc:
return [*failures, f"the workspace isn't usable: {exc}"]
try:
git.push(self._remote, check, credential=self._credential)
except GitError as exc:
failures.append(f"my push credential can't push a branch: {exc}")
self._back_to_base(check)
return failures
try:
git.push(self._remote, base, credential=self._credential)
failures.append(
f"I could push to {base}, which protection should refuse; an "
f"empty commit, 'nisse-dev: access check', is on {base} now"
)
except GitError:
pass # refused, as it should be
pr = None
try:
pr = self._gitea.open_pull_request(
self._repo,
head=check,
base=base,
title="nisse-dev access check (never merged)",
body="Checks that merging needs an approval; closed straight away.",
)
try:
self._gitea.merge_pull_request(self._repo, pr.number)
failures.append(
f"I could merge my own PR #{pr.number} without an approval"
)
except GiteaError:
pass # refused, as it should be
except GiteaError as exc:
failures.append(f"I couldn't open a PR to check merging: {exc}")
finally:
if pr is not None:
with contextlib.suppress(GiteaError):
self._gitea.close_pull_request(self._repo, pr.number)
with contextlib.suppress(GitError):
git.delete_remote_branch(
self._remote, check, credential=self._credential
)
self._back_to_base(check)
return failures
def _back_to_base(self, branch: str) -> None:
with contextlib.suppress(GitError):
self._git.reset_to(self._base, f"origin/{self._base}")
self._git._git("branch", "-q", "-D", branch)
def _ahead_of_origin(self) -> bool: def _ahead_of_origin(self) -> bool:
current = self._git.current_branch() current = self._git.current_branch()
try: try:
+15
View File
@@ -85,6 +85,20 @@ PROPOSE_WORK = _tool(
"whole brief.", "whole brief.",
) )
PROPOSE_PROJECT = _tool(
"propose_project",
"The conversation has become a new project: propose it. The operator "
"agrees to it as to a restatement; then you list what it needs set up "
"(a repository, a build VM, the project's entry), and once it is ready "
"and checked, the project's architecture is your first piece of work. "
"Ends your turn.",
name="The project's name: lowercase letters, digits and -.",
purpose="What it is for, in a paragraph.",
decisions="The decisions setting it up depends on: language and toolchain "
"(which pick the build VM), services, secrets, network access beyond the "
"default; and anything else agreed so far.",
)
WAITING = frozenset( WAITING = frozenset(
{ {
"restate", "restate",
@@ -94,6 +108,7 @@ WAITING = frozenset(
"wait_for_review", "wait_for_review",
"reply", "reply",
"propose_work", "propose_work",
"propose_project",
} }
) )
+27 -1
View File
@@ -14,7 +14,7 @@ from nisse_dev.cancel import Cancel
from nisse_dev.canonical import ToolCall from nisse_dev.canonical import ToolCall
from nisse_dev.execution import SSHHost from nisse_dev.execution import SSHHost
from nisse_dev.git import GitRepo from nisse_dev.git import GitRepo
from nisse_dev.gitea import Comment, Issue, PullRequest, ReviewNote from nisse_dev.gitea import Comment, GiteaError, Issue, PullRequest, ReviewNote
from nisse_dev.loop.agent import LoopAgent from nisse_dev.loop.agent import LoopAgent
from nisse_dev.loop.driver import Driver from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of
@@ -124,6 +124,32 @@ class Forge:
def close_pull_request(self, repo: str, index: int) -> None: def close_pull_request(self, repo: str, index: int) -> None:
self.prs[index]["state"] = "closed" self.prs[index]["state"] = "closed"
# what a new project's checks read (D4)
protected = False
def protect(self) -> None:
"""Protection as the server enforces it: pushes to main refused by a
hook; merging needs an approval nobody here gives."""
hook = self.bare / "hooks" / "pre-receive"
hook.write_text(
"#!/bin/sh\nwhile read old new ref; do\n"
' [ "$ref" = refs/heads/main ] && echo "main is protected" && exit 1\n'
"done\nexit 0\n"
)
hook.chmod(0o755)
self.protected = True
def repository(self, repo: str) -> dict[str, Any]:
return {"permissions": {"push": True}}
def branch(self, repo: str, name: str) -> dict[str, Any]:
return {"protected": self.protected, "user_can_push": not self.protected}
def merge_pull_request(self, repo: str, index: int) -> None:
if self.protected:
raise GiteaError("gitea POST merge returned 405: approvals needed")
self.merge(index)
# what the operator does in Gitea # what the operator does in Gitea
def merge(self, n: int) -> None: def merge(self, n: int) -> None:
head = f"refs/heads/{self.prs[n]['head']}" head = f"refs/heads/{self.prs[n]['head']}"
+42
View File
@@ -19,6 +19,7 @@ from test_loop_milestones import (
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_dev.canonical import ToolCall, ToolResult from nisse_dev.canonical import ToolCall, ToolResult
from nisse_dev.gitea import Issue
from nisse_dev.loop.items import MERGE, WAITING, loop_of from nisse_dev.loop.items import MERGE, WAITING, loop_of
@@ -174,3 +175,44 @@ def test_an_unsaid_final_closes_the_issue(world: dict[str, Any]) -> None:
say(transport, world, "go") say(transport, world, "go")
[pr] = forge.prs [pr] = forge.prs
assert "Closes #14" in forge.prs[pr]["body"] assert "Closes #14" in forge.prs[pr]["body"]
def test_a_design_milestone_holds_documents_only(world: dict[str, Any]) -> None:
"""D1, D2: a `design` item agrees its design before any code; the
harness refuses a design milestone with code in it."""
forge: Forge = world["forge"]
forge.issues[0] = Issue(14, "Add count_words", "count words", "don", ("design",))
model = ScriptedModel(
RESTATE,
[
call("start_milestone", name="design"),
call("write_file", path="ARCHITECTURE.md", content="# arch\n"),
call("write_file", path="docs/plans/words.md", content="# plan\n"),
call("write_file", path="words.py", content="W = 1\n"), # too soon
call("finish_milestone", summary="design", final="no"),
],
[
call("run_command", command="rm words.py"),
call("finish_milestone", summary="design", final="no"),
],
milestone("words.py", final="yes"), # after the design merged
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
[design] = forge.prs
assert sorted(forge.files(design)) == ["ARCHITECTURE.md", "docs/plans/words.md"]
refused = [
e.event.result
for e in world["store"].transcript(item(world).id, "driver").read()
if isinstance(e.event, ToolResult)
and "this milestone is the design" in e.event.result
]
assert refused and "words.py" in refused[0]
forge.merge(design)
agent.watch()
agent._schedule()
code = max(forge.prs)
assert code != design and forge.files(code) == ["words.py"]
assert loop_of(item(world)).design_merged
+193
View File
@@ -0,0 +1,193 @@
"""New projects on the new loop (M2.5, PR B): a conversation proposes one,
the agent lists what it needs, and on "done" reads its configuration again
and checks what was provided (D1, D4, D8) before opening the tracking issue
and starting on the architecture. Real git on the throwaway build host,
with protection enforced by a hook on the fake forge's bare repository."""
import io
import re
from pathlib import Path
from typing import Any
import pytest
import yaml
from test_loop_milestones import DON, Forge, ScriptedModel, call, make_world
from test_loop_projects import Router
from nisse_dev.cancel import Cancel
from nisse_dev.config import _parse_projects
from nisse_dev.execution import SSHHost
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, PROVISION, loop_of
from nisse_dev.oplog import OpLog
from nisse_dev.tools import Workspace
from nisse_dev.transport import InMemoryTransport
from nisse_dev.worker import InlineWorker
PROPOSAL = call(
"propose_project",
name="tally",
purpose="A small command-line tally counter.",
decisions="Python 3.12 with pytest; no services, secrets or extra egress.",
)
@pytest.fixture
def worlds(ssh_host: SSHHost, tmp_path: Path) -> dict[str, Any]:
sandbox = make_world(ssh_host, tmp_path / "sandbox")
tally = make_world(
ssh_host, tmp_path / "tally", store=sandbox["store"], repo_name="don/tally"
)
sandbox["forge"].issues.clear() # only the conversation here
tally["forge"].issues.clear()
return {"sandbox": sandbox, "tally": tally}
def agent_for(
worlds: dict[str, Any], model: ScriptedModel, *, entry_added: list[bool]
) -> tuple[LoopAgent, InMemoryTransport]:
"""An agent that knows only the sandbox; tally's entry appears in its
configuration once ``entry_added[0]`` is set."""
transport = InMemoryTransport()
cancel = Cancel()
router = Router({w["repo_name"]: w["forge"] for w in worlds.values()})
for w in worlds.values():
w["milestones"]._gitea = router
def runtime(name: str) -> Project:
w = worlds[name]
return Project(
name=name,
repo=w["repo_name"],
workspace=Workspace(w["host"], w["repo"].root, timeout_s=30),
milestones=w["milestones"],
)
def reload(known: set[str]) -> list[Project]:
names = ["sandbox", "tally"] if entry_added[0] else ["sandbox"]
return [runtime(n) for n in names if n not in known]
agent = LoopAgent(
worlds["sandbox"]["store"],
transport,
router, # type: ignore[arg-type]
projects=[runtime("sandbox")],
account="nisse-dev",
operators={DON},
gitea_operators={"don"},
driver=Driver(model, "m", cancel=cancel), # type: ignore[arg-type]
worker=InlineWorker(),
cancel=cancel,
oplog=OpLog("nisse-dev", io.StringIO()),
reload=reload,
)
transport.on_message(agent.on_message)
return agent, transport
def conversation(worlds: dict[str, Any]) -> Any:
[chat] = [
i for i in worlds["sandbox"]["store"].work_items() if i.scope == "general"
]
return chat
def proposed(
worlds: Any, model: ScriptedModel, added: list[bool]
) -> tuple[Any, Any, str]:
agent, transport = agent_for(worlds, model, entry_added=added)
root = transport.receive(DON, "I want a little tally counter, as its own project.")
assert transport.outbound[-1].text.startswith("rev-1: a new project, `tally`.")
assert loop_of(conversation(worlds)).wait == GO_AHEAD
transport.receive(DON, "go", thread=root)
return agent, transport, root
def test_an_agreed_project_gets_a_checklist_with_its_entry(worlds: Any) -> None:
_, transport, _ = proposed(worlds, ScriptedModel([PROPOSAL]), [False])
loop = loop_of(conversation(worlds))
assert (loop.wait, loop.new_project) == (PROVISION, "tally")
checklist = transport.outbound[-1].text
for need in ("repository", "Protection", "collaborator", "build VM", "deploy key"):
assert need in checklist
# The entry is valid configuration once its placeholders are filled in.
entry = re.search(r"```yaml\n(.*?)\n```", checklist, re.DOTALL)
assert entry is not None
filled = (
entry.group(1)
.replace("<owner>/tally", "don/tally")
.replace("<the build VM>", "build.test")
.replace("<my SSH key for it>", "/k")
.replace("<the deploy key's private half>", "/p")
)
errors: list[str] = []
[project] = _parse_projects(yaml.safe_load(filled)["projects"], {}, errors)
assert errors == [] and project.name == "tally" and project.repo == "don/tally"
def test_done_before_the_entry_is_added_says_so(worlds: Any) -> None:
_, transport, root = proposed(worlds, ScriptedModel([PROPOSAL]), [False])
transport.receive(DON, "done", thread=root)
assert "I don't see `tally` in my configuration yet" in transport.outbound[-1].text
assert loop_of(conversation(worlds)).new_project == "tally"
def test_what_isnt_set_up_is_named_and_nothing_starts(worlds: Any) -> None:
added = [False]
_, transport, root = proposed(worlds, ScriptedModel([PROPOSAL]), added)
added[0] = True # the entry is there, but tally's main isn't protected
transport.receive(DON, "done", thread=root)
said = transport.outbound[-1].text
assert said.startswith("Not ready yet:")
assert "main isn't protected" in said and "could merge my own PR" in said
assert loop_of(conversation(worlds)).new_project == "tally"
assert worlds["tally"]["forge"].issues == []
# The check's PR was closed and its branch deleted.
assert all(p["state"] == "closed" for p in worlds["tally"]["forge"].prs.values())
def test_a_checked_project_gets_its_record_and_starts_on_its_design(
worlds: Any,
) -> None:
added = [False]
model = ScriptedModel(
[PROPOSAL],
[ # tally's first item: the architecture and the build plan
call("start_milestone", name="architecture"),
call("write_file", path="ARCHITECTURE.md", content="# tally\n"),
call("write_file", path="docs/plans/tally.md", content="# plan\n"),
call("finish_milestone", summary="architecture and plan", final="no"),
],
)
agent, transport, root = proposed(worlds, model, added)
forge: Forge = worlds["tally"]["forge"]
forge.protect()
added[0] = True
transport.receive(DON, "done", thread=root)
assert "tally" in agent._projects
[record] = forge.issues
marker = re.search(r"<!-- nisse-item: ([\w-]+) -->", record.body)
assert marker and "tally counter" in record.body
work = worlds["sandbox"]["store"].work_item(marker.group(1))
assert work.scope == "tally" and loop_of(work).started
assert "design" in loop_of(work).labels
assert loop_of(work).wait == MERGE
pr = max(n for n, p in forge.prs.items() if p["state"] == "open")
assert sorted(forge.files(pr)) == ["ARCHITECTURE.md", "docs/plans/tally.md"]
assert f"Part of #{record.number}" in forge.prs[pr]["body"]
chat = loop_of(conversation(worlds))
assert chat.became == (f"don/tally#{record.number}",)
assert (chat.wait, chat.new_project) == (CHAT, "")
def test_a_project_name_that_exists_is_sent_back(worlds: Any) -> None:
model = ScriptedModel(
[call("propose_project", name="sandbox", purpose="again", decisions="x.")],
[call("reply", text="sandbox exists; shall I propose work on it?")],
)
agent, transport = agent_for(worlds, model, entry_added=[False])
transport.receive(DON, "a new sandbox?")
assert loop_of(conversation(worlds)).wait == CHAT
assert agent
+41 -4
View File
@@ -11,6 +11,7 @@ the seed and the steps, so it replays exactly.
import random import random
import re import re
import subprocess import subprocess
import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -32,6 +33,7 @@ from nisse_dev.loop.items import (
CHAT, CHAT,
GO_AHEAD, GO_AHEAD,
MERGE, MERGE,
PROVISION,
RUNNING, RUNNING,
WAITING, WAITING,
loop_of, loop_of,
@@ -61,12 +63,26 @@ class PolicyModel:
self.n += 1 self.n += 1
offered = {t["function"]["name"] for t in tools} offered = {t["function"]["name"] for t in tools}
facts = [getattr(e.event, "text", "") for e in events] facts = [getattr(e.event, "text", "") for e in events]
# A `design` item writes documents until its design has merged.
design = "labelled design" in (system or "") and not any(
"the design has merged" in t for t in facts
)
path = f"docs/d{self.n}.md" if design else f"f{self.n}.py"
roll = self.rng.random() roll = self.rng.random()
if roll < 0.08: if roll < 0.08:
return [call("ask_human", question=f"question {self.n}?")] return [call("ask_human", question=f"question {self.n}?")]
if roll < 0.12: if roll < 0.12:
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})] return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})]
if "reply" in offered: # a conversation: talk, or propose work if "reply" in offered: # a conversation: talk, or propose work
if self.rng.random() < 0.1:
return [
call(
"propose_project",
name=f"proj{self.n}",
purpose=f"a project {self.n}.",
decisions="Python.",
)
]
if self.rng.random() < 0.4: if self.rng.random() < 0.4:
return [ return [
call( call(
@@ -87,7 +103,7 @@ class PolicyModel:
# Half done, then a question: work a switch must park. # Half done, then a question: work a switch must park.
return [ return [
call("start_milestone", name=f"m{self.n}"), call("start_milestone", name=f"m{self.n}"),
call("write_file", path=f"f{self.n}.py", content=f"x = {self.n}\n"), call("write_file", path=path, content=f"x = {self.n}\n"),
call("ask_human", question=f"halfway {self.n}?"), call("ask_human", question=f"halfway {self.n}?"),
] ]
merged = any("merged" in t for t in facts[-6:]) merged = any("merged" in t for t in facts[-6:])
@@ -95,7 +111,7 @@ class PolicyModel:
return [call("close_item", summary=f"done {self.n}")] return [call("close_item", summary=f"done {self.n}")]
return [ return [
call("start_milestone", name=f"m{self.n}"), call("start_milestone", name=f"m{self.n}"),
call("write_file", path=f"f{self.n}.py", content=f"x = {self.n}\n"), call("write_file", path=path, content=f"x = {self.n}\n"),
call( call(
"finish_milestone", "finish_milestone",
summary=f"work {self.n}", summary=f"work {self.n}",
@@ -125,6 +141,10 @@ class Walk:
def _agent(self) -> tuple[LoopAgent, InMemoryTransport]: def _agent(self) -> tuple[LoopAgent, InMemoryTransport]:
transport = InMemoryTransport() transport = InMemoryTransport()
# Event IDs are unique across restarts, as a server's are: a new
# transport goes on numbering where the old one stopped.
old = getattr(self, "transport", None)
transport._next_id = old._next_id if old is not None else 0
cancel = Cancel() cancel = Cancel()
router = Router({w["repo_name"]: w["forge"] for w in self.worlds.values()}) router = Router({w["repo_name"]: w["forge"] for w in self.worlds.values()})
for w in self.worlds.values(): for w in self.worlds.values():
@@ -193,6 +213,8 @@ class Walk:
if item and loop: if item and loop:
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1}) weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
weights.update({"plain-yes": 2, "wait": 2}) weights.update({"plain-yes": 2, "wait": 2})
if loop.new_project:
weights.update({"done": 2})
if item.scope != "general": # conversations aren't named by number if item.scope != "general": # conversations aren't named by number
weights.update({"first": 1, "named-go": 1}) weights.update({"first": 1, "named-go": 1})
if loop.pr: if loop.pr:
@@ -203,6 +225,9 @@ class Walk:
self.steps.append(f"{action}{self.name(item)}" if item else action) self.steps.append(f"{action}{self.name(item)}" if item else action)
if action == "intake": if action == "intake":
self.agent.intake() self.agent.intake()
elif action == "done" and loop:
# The operator says a new project is ready; no entry was added.
self.transport.receive(DON, "done", thread=loop.thread)
elif action == "chat": elif action == "chat":
# A top-level message: a conversation of its own (L1, L5). # A top-level message: a conversation of its own (L1, L5).
self.transport.receive(DON, f"thinking aloud {len(self.steps)}") self.transport.receive(DON, f"thinking aloud {len(self.steps)}")
@@ -295,6 +320,7 @@ class Walk:
if item.status == "open" and item.position == WAITING: if item.status == "open" and item.position == WAITING:
explained = ( explained = (
(loop.wait == CHAT and item.scope == "general") (loop.wait == CHAT and item.scope == "general")
or (loop.wait == PROVISION and bool(loop.new_project))
or (loop.wait == GO_AHEAD and loop.rev > 0) or (loop.wait == GO_AHEAD and loop.rev > 0)
or (loop.wait == ANSWER and bool(loop.note)) or (loop.wait == ANSWER and bool(loop.note))
or (loop.wait == MERGE and loop.pr > 0) or (loop.wait == MERGE and loop.pr > 0)
@@ -546,8 +572,18 @@ def _is_ancestor(bare: Path, commit: str, ref: str) -> bool:
) )
def _seeded_ids(monkeypatch: pytest.MonkeyPatch, seed: int) -> None:
"""Item IDs and branch names come from uuid4: seeded too, so a walk
replays exactly from its seed."""
ids = random.Random(seed)
monkeypatch.setattr(uuid, "uuid4", lambda: uuid.UUID(int=ids.getrandbits(128)))
@pytest.mark.parametrize("seed", SEEDS) @pytest.mark.parametrize("seed", SEEDS)
def test_the_loop_keeps_its_properties(world: dict[str, Any], seed: int) -> None: def test_the_loop_keeps_its_properties(
world: dict[str, Any], seed: int, monkeypatch: pytest.MonkeyPatch
) -> None:
_seeded_ids(monkeypatch, seed)
walk = Walk({"sandbox": world}, seed) walk = Walk({"sandbox": world}, seed)
for _ in range(STEPS): for _ in range(STEPS):
walk.step() walk.step()
@@ -556,9 +592,10 @@ def test_the_loop_keeps_its_properties(world: dict[str, Any], seed: int) -> None
@pytest.mark.parametrize("seed", TWO_PROJECT_SEEDS) @pytest.mark.parametrize("seed", TWO_PROJECT_SEEDS)
def test_two_projects_keep_the_properties( def test_two_projects_keep_the_properties(
ssh_host: SSHHost, tmp_path: Path, seed: int ssh_host: SSHHost, tmp_path: Path, seed: int, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""D8: the same walk over two projects, whose issue numbers collide.""" """D8: the same walk over two projects, whose issue numbers collide."""
_seeded_ids(monkeypatch, seed)
sandbox = make_world(ssh_host, tmp_path / "sandbox") sandbox = make_world(ssh_host, tmp_path / "sandbox")
other = make_world( other = make_world(
ssh_host, tmp_path / "other", store=sandbox["store"], repo_name="don/other" ssh_host, tmp_path / "other", store=sandbox["store"], repo_name="don/other"
+15 -8
View File
@@ -61,7 +61,7 @@ class FreshRepo:
@contextlib.contextmanager @contextlib.contextmanager
def fresh_repo( def fresh_repo(
operator: GiteaClient, run: str, keys: Path, *, agent: str operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
) -> Iterator[FreshRepo]: ) -> Iterator[FreshRepo]:
"""A repository of its own for one test run, as the operator would make a """A repository of its own for one test run, as the operator would make a
project's (D4, D8): seeded with a small Python package, the ``agent`` project's (D4, D8): seeded with a small Python package, the ``agent``
@@ -88,14 +88,21 @@ def fresh_repo(
"content": base64.b64encode(path.read_bytes()).decode(), "content": base64.b64encode(path.read_bytes()).decode(),
} }
for path in sorted(SEED.rglob("*")) for path in sorted(SEED.rglob("*"))
if path.is_file() and path.name != "README.md" if seed and path.is_file() and path.name != "README.md"
] ]
operator._request( if files:
"POST", operator._request(
f"/repos/{full}/contents", "POST",
json={"branch": "main", "message": "Seed the test project", "files": files}, f"/repos/{full}/contents",
) json={
"branch": "main",
"message": "Seed the test project",
"files": files,
},
)
readme = operator._request("GET", f"/repos/{full}/contents/README.md") readme = operator._request("GET", f"/repos/{full}/contents/README.md")
# A new project's README says only what it is; a seeded one, the seed's.
text = (SEED / "README.md").read_bytes() if seed else f"# {name}\n".encode()
operator._request( operator._request(
"PUT", "PUT",
f"/repos/{full}/contents/README.md", f"/repos/{full}/contents/README.md",
@@ -103,7 +110,7 @@ def fresh_repo(
"branch": "main", "branch": "main",
"message": "README", "message": "README",
"sha": readme["sha"], "sha": readme["sha"],
"content": base64.b64encode((SEED / "README.md").read_bytes()).decode(), "content": base64.b64encode(text).decode(),
}, },
) )
operator._request( operator._request(
+4
View File
@@ -27,6 +27,10 @@ projects:
llm: llm:
base_url: https://litellm.dongar.net base_url: https://litellm.dongar.net
token: ${NISSE_LLM_TOKEN} token: ${NISSE_LLM_TOKEN}
# The test key caps each request's worst-case cost ($0.10): input plus
# this much reserved output. The test agent's replies are short, so half
# the default leaves room for ~40k input tokens on Haiku.
max_output_tokens: 8192
# The live tests exercise the gated loop and the routes over a real proxy, # The live tests exercise the gated loop and the routes over a real proxy,
# not local-model competence (measured separately by test_model_capability), # not local-model competence (measured separately by test_model_capability),
+92 -3
View File
@@ -12,6 +12,7 @@ scripts/e2e.sh after each milestone.
import base64 import base64
import contextlib import contextlib
import os import os
import re
import time import time
import uuid import uuid
from collections.abc import Iterator from collections.abc import Iterator
@@ -83,7 +84,11 @@ def running(
monkeypatch.setattr("nisse_dev.loop.build.MatrixTransport", lambda cfg: transport) monkeypatch.setattr("nisse_dev.loop.build.MatrixTransport", lambda cfg: transport)
agent = None agent = None
try: try:
agent = build_loop_agent(config) # What the agent reads when told a new project is ready: replaced by
# a test that adds one (D8).
source = [config]
agent = build_loop_agent(config, load=lambda: source[0])
agent.source = source # type: ignore[attr-defined]
# The test drives intake and PR watching itself, not on a timer. # The test drives intake and PR watching itself, not on a timer.
agent.loop._interval = 3600 agent.loop._interval = 3600
agent.start() agent.start()
@@ -145,11 +150,13 @@ class Operator:
"""Intake and PR watching, as the agent's timer would do.""" """Intake and PR watching, as the agent's timer would do."""
self.agent.worker.call(self.agent.loop.intake, timeout=DEADLINE_S) self.agent.worker.call(self.agent.loop.intake, timeout=DEADLINE_S)
project: str | None = None # whose issues ``issue`` numbers, if not the only
def item(self, issue: int = 0) -> Any: def item(self, issue: int = 0) -> Any:
"""The work item for ``issue``; by default the first one.""" """The work item for ``issue``; by default the first one."""
if issue: if issue:
store = self.agent.store store, project = self.agent.store, self.project
return self.agent.worker.call(lambda: item_for_issue(store, issue)) return self.agent.worker.call(lambda: item_for_issue(store, issue, project))
items = self.agent.worker.call(self.agent.store.work_items) items = self.agent.worker.call(self.agent.store.work_items)
return items[0] if items else None return items[0] if items else None
@@ -545,3 +552,85 @@ def test_a_conversation_becomes_filed_work(
assert f"Closes #{number}" in pr["body"] assert f"Closes #{number}" in pr["body"]
finally: finally:
operator.tidy(issues, prs) operator.tidy(issues, prs)
def test_a_conversation_becomes_a_new_project(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.5: a conversation proposes a new project; the operator provides
what the checklist asks for (here, a repository of its own made the way
fresh_repo makes one, and the entry the agent wrote); "done" has the
agent check it all, open the tracking issue, and start the architecture."""
import yaml
from nisse_dev.config import _parse_projects
agent, transport, run = running
operator = Operator(agent, transport)
as_operator = operator_gitea(agent.config.gitea_url)
name = f"tally-{run}"
def conversation() -> Any:
items = agent.worker.call(agent.store.work_items)
return next(i for i in items if i.scope == "general")
root = transport.receive(
OPERATOR,
f"I'd like a new project, named {name}: a tiny command-line tally "
"counter in Python 3 with pytest, no services, secrets or extra network "
"access. Please propose it as a new project.",
)
operator.settle()
for _ in range(MAX_ANSWERS):
if loop_of(conversation()).wait == GO_AHEAD:
break
transport.receive(
OPERATOR, f"Yes: propose it as a new project named {name}.", thread=root
)
operator.settle()
assert loop_of(conversation()).proposal_kind == "project", operator.last()
transport.receive(OPERATOR, "go", thread=root)
operator.settle()
checklist = operator.last()
entry = re.search(r"```yaml\n(.*?)\n```", checklist, re.DOTALL)
assert entry is not None, checklist
# --- the operator provides it all, as the checklist asks ---
keep = agent.config.state_db.parent
with fresh_repo(
as_operator, f"{run}-new", keep / "keys", agent=agent.account, seed=False
) as repo:
host = agent.config.build_host
filled = (
entry.group(1)
.replace(f"<owner>/{name}", repo.name)
.replace("<the build VM>", host.address)
.replace("<my SSH key for it>", str(host.key))
.replace("<the deploy key's private half>", str(repo.push_key))
)
errors: list[str] = []
[project] = _parse_projects(yaml.safe_load(filled)["projects"], {}, errors)
assert errors == [], errors
# On this run's build host and in its scratch directory.
project = replace(
project, build_host=host, workspace=Path(f"/tmp/nisse-e2e-{run}/{name}")
)
agent.source[0] = replace( # type: ignore[attr-defined]
agent.config, projects=(*agent.config.projects, project)
)
transport.receive(OPERATOR, "done", thread=root)
operator.settle()
became = loop_of(conversation()).became
assert became, f"not set up: {operator.last()}"
number = int(became[0].split("#")[1])
operator.project = name
item = operator.drive_until(MERGE, number)
assert "design" in loop_of(item).labels
pr = loop_of(item).pr
files = [
f["filename"]
for f in agent.gitea._request("GET", f"/repos/{repo.name}/pulls/{pr}/files")
]
assert "ARCHITECTURE.md" in files, files
assert any(f.startswith("docs/plans/") for f in files), files