Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s

- Config: `projects: {name: entry}` with repo, base_branch, workspace,
  build_host, push credential, labels (defaults added to every issue),
  secrets by ${VAR} reference with visibility (commands | harness), and
  egress (recorded, not enforced). Every problem in every entry is
  reported at once; an inline secret, a bad visibility, a repeated repo or
  a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
  commands-visible secrets, redacted on return), push credential and
  milestones; intake polls every project's repository; items are scoped
  to their project for every run, publish and PR watch. Issues are named
  project#14 when there is more than one project, and a bare #14 that
  matches two is asked about. Switching projects parks the one worked on
  last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
  posted: the e2e driver kept asking "Is this the right interpretation?",
  which a plain yes can't answer.

Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.

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:
dongarandClaude Opus 5.5 committed 2026-10-06 01:12:57 +00:00
1 parent 4ecff19933
commit 064d9bf554
17 files changed
+1139 -315

No files matched your search

+92 -32
View File
@@ -15,13 +15,15 @@ from typing import Any
import pytest
from model_contract import assert_renderable
from test_loop_milestones import DON, Forge, call
from test_loop_milestones import DON, Forge, call, make_world
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from test_loop_projects import Router
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import Fact, PlanSubmitted, ToolCall, UserMessage
from nisse_dev.execution import SSHHost
from nisse_dev.gitea import Comment, Issue
from nisse_dev.loop.agent import LoopAgent
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import (
ANSWER,
@@ -38,6 +40,7 @@ from nisse_dev.transport import InboundMessage, InMemoryTransport
from nisse_dev.worker import InlineWorker
SEEDS = range(32)
TWO_PROJECT_SEEDS = range(100, 116)
STEPS = 30
@@ -88,13 +91,17 @@ class PolicyModel:
class Walk:
def __init__(self, world: dict[str, Any], seed: int) -> None:
self.world = world
"""``worlds``: project name -> its world (forge, bare repo, clone,
milestones), all over one store."""
def __init__(self, worlds: dict[str, dict[str, Any]], seed: int) -> None:
self.worlds = worlds
self.world = next(iter(worlds.values())) # the store's home
self.rng = random.Random(seed)
self.seed = seed
self.steps: list[str] = []
self.shown_at_go: list[str] = [] # item:revision on screen at each go
self.next_issue = 15
self.next_issue = dict.fromkeys(worlds, 15) # numbers repeat across projects
self.now = 1000.0
self.agent, self.transport = self._agent()
@@ -106,22 +113,29 @@ class Walk:
def _agent(self) -> tuple[LoopAgent, InMemoryTransport]:
transport = InMemoryTransport()
cancel = Cancel()
w = self.world
router = Router({w["repo_name"]: w["forge"] for w in self.worlds.values()})
for w in self.worlds.values():
w["milestones"]._gitea = router
agent = LoopAgent(
w["store"],
self.world["store"],
transport,
w["forge"],
repo="don/sandbox",
project="sandbox",
router, # type: ignore[arg-type]
projects=[
Project(
name=name,
repo=w["repo_name"],
workspace=Workspace(w["host"], w["repo"].root, timeout_s=30),
milestones=w["milestones"],
)
for name, w in self.worlds.items()
],
account="nisse-dev",
operators={DON},
gitea_operators={"don"},
driver=Driver(PolicyModel(self.rng), "m", cancel=cancel), # type: ignore[arg-type]
workspace=Workspace(w["host"], w["repo"].root, timeout_s=30),
worker=InlineWorker(),
cancel=cancel,
oplog=OpLog("nisse-dev", _Null()),
milestones=w["milestones"],
reader=self, # type: ignore[arg-type]
bug_grace_s=600,
clock=lambda: self.now,
@@ -135,14 +149,21 @@ class Walk:
def items(self) -> list[Any]:
return self.world["store"].work_items()
def name(self, item: Any) -> str:
"""How the operator names an item: by project when there are two."""
issue = loop_of(item).issue
return f"#{issue}" if len(self.worlds) == 1 else f"{item.scope}#{issue}"
def step(self) -> None:
forge: Forge = self.world["forge"]
open_items = [i for i in self.items() if i.status == "open"]
# Weighted towards what an item can actually do, so review paths
# are walked often, not drowned out by intake and crashes.
weights = {"intake": 2 if not open_items else 1, "crash": 1, "issue": 1}
item = self.rng.choice(open_items) if open_items else None
loop = loop_of(item) if item else None
# The forge an item's project lives on; a new issue picks one.
project = item.scope if item else self.rng.choice(list(self.worlds))
forge: Forge = self.worlds[project]["forge"]
if item and loop:
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
weights.update({"first": 1, "plain-yes": 2, "named-go": 1, "wait": 2})
@@ -151,12 +172,15 @@ class Walk:
{"comment": 4, "merge": 2, "close-pr": 1, "comment-merge": 2}
)
action = self.rng.choices(list(weights), list(weights.values()))[0]
self.steps.append(f"{action}#{loop.issue}" if loop else action)
self.steps.append(f"{action}{self.name(item)}" if item else action)
if action == "intake":
self.agent.intake()
elif action == "issue":
# A new issue, sometimes depending on one that is still open.
n, self.next_issue = self.next_issue, self.next_issue + 1
project = self.rng.choice(list(self.worlds))
forge = self.worlds[project]["forge"]
n = self.next_issue[project]
self.next_issue[project] += 1
labels = self.rng.choice([(), (), ("bug",), ("discuss",), ("design",)])
forge.issues.append(Issue(n, f"task {n}", f"do task {n}", "don", labels))
live = [i.number for i in forge.assigned_issues("", "") if i.number != n]
@@ -171,7 +195,7 @@ class Walk:
elif action == "named-go" and loop:
if item.position == WAITING and loop.wait == GO_AHEAD:
self.shown_at_go.append(f"{item.id}:{loop.rev}")
self.transport.receive(DON, f"go #{loop.issue}")
self.transport.receive(DON, f"go {self.name(item)}")
elif action == "wait":
# Time passes: bug countdowns may run out.
self.now += self.rng.choice([60, 700])
@@ -185,7 +209,7 @@ class Walk:
self.shown_at_go.append(f"{other.id}:{o.rev}")
self.agent.intake()
elif action == "first" and loop:
self.agent.handle(InboundMessage(DON, f"do #{loop.issue} next"), None)
self.agent.handle(InboundMessage(DON, f"do {self.name(item)} next"), None)
elif action == "crash":
# Volatile state is lost: a new agent over the same store.
self.agent, self.transport = self._agent()
@@ -231,12 +255,14 @@ class Walk:
def check(self) -> None:
where = f"seed {self.seed}, steps {self.steps}"
store, forge = self.world["store"], self.world["forge"]
store = self.world["store"]
items = self.items()
issues = [loop_of(i).issue for i in items if loop_of(i).issue]
issues = [(i.scope, loop_of(i).issue) for i in items if loop_of(i).issue]
assert len(issues) == len(set(issues)), f"one_item_per_issue: {where}"
for item in items:
loop = loop_of(item)
forge = self.worlds[item.scope]["forge"]
repo = self.worlds[item.scope]["repo_name"]
if item.status == "open" and item.position == WAITING:
explained = (
(loop.wait == GO_AHEAD and loop.rev > 0)
@@ -257,7 +283,7 @@ class Walk:
)
# Blocked comes from external truth (L1): an item whose issue
# had an open dependency from the start never ran before it closed.
if forge.open_dependencies("don/sandbox", loop.issue):
if forge.open_dependencies(repo, loop.issue):
assert item.position == BLOCKED or item.status == "closed", (
f"blocked_never_runs: #{loop.issue} {item.position}: {where}"
)
@@ -311,11 +337,30 @@ class Walk:
assert not [i for i in items if i.position == RUNNING], (
f"one_active (nothing left running): {where}"
)
self._parked_work_safe(where)
self._milestones_start_fresh(where)
for name, w in self.worlds.items():
self._own_repository_only(name, w, where)
self._parked_work_safe(w, name, where)
self._milestones_start_fresh(w, where)
self._review_fixes_land(w, where)
def _own_repository_only(self, name: str, world: Any, where: str) -> None:
"""D8: an item's branches and PRs reach only its own project's
repository."""
others = [i.id for i in self.items() if i.scope != name]
heads = [p["head"] for p in world["forge"].prs.values()]
branches = _git(
world["bare"], "for-each-ref", "--format=%(refname:short)", "refs/heads"
).split()
for ref in [*heads, *branches]:
assert not any(ref.startswith(f"{i}-") for i in others), (
f"own_repository_only: {ref} in {name}: {where}"
)
def _review_fixes_land(self, world: Any, where: str) -> None:
forge: Forge = world["forge"]
heads = [p["head"] for p in forge.prs.values()]
assert len(heads) == len(set(heads)), f"one_pr_per_milestone: {where}"
bare = self.world["bare"]
bare = world["bare"]
for n, pr in forge.prs.items():
if not pr["merged"]:
continue
@@ -330,11 +375,11 @@ class Walk:
)
assert landed, f"review_fixes_land (PR {n}): {where}"
def _milestones_start_fresh(self, where: str) -> None:
def _milestones_start_fresh(self, world: Any, where: str) -> None:
"""D4: a milestone starts from the latest default branch, so one
redone after a closed PR never carries the rejected work."""
forge: Forge = self.world["forge"]
bare = self.world["bare"]
forge: Forge = world["forge"]
bare = world["bare"]
for item in self.items():
mine = sorted(
(n, p)
@@ -353,13 +398,13 @@ class Walk:
bare, rejected, f"refs/heads/{later['head']}"
), f"milestones_start_fresh: PR {m} carries PR {n}: {where}"
def _parked_work_safe(self, where: str) -> None:
def _parked_work_safe(self, world: Any, name: str, where: str) -> None:
"""L1, D4: nothing of a parked item lives only on the build VM: what
every branch of an open item that isn't on the workspace points at is
in the forge."""
root, bare = self.world["repo"].root, self.world["bare"]
in its project's forge."""
root, bare = world["repo"].root, world["bare"]
current = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
ids = [i.id for i in self.items() if i.status == "open"]
ids = [i.id for i in self.items() if i.status == "open" and i.scope == name]
for line in _git(
root, "for-each-ref", "--format=%(refname:short)", "refs/heads"
).splitlines():
@@ -384,7 +429,7 @@ class Walk:
# And no write is lost: every file an open item's driver wrote is on
# the workspace while it's that item's, or in the forge.
for item in self.items():
if item.status != "open":
if item.status != "open" or item.scope != name:
continue
written = {
str(e.event.arguments.get("path"))
@@ -444,7 +489,22 @@ def _is_ancestor(bare: Path, commit: str, ref: str) -> bool:
@pytest.mark.parametrize("seed", SEEDS)
def test_the_loop_keeps_its_properties(world: dict[str, Any], seed: int) -> None:
walk = Walk(world, seed)
walk = Walk({"sandbox": world}, seed)
for _ in range(STEPS):
walk.step()
walk.check()
@pytest.mark.parametrize("seed", TWO_PROJECT_SEEDS)
def test_two_projects_keep_the_properties(
ssh_host: SSHHost, tmp_path: Path, seed: int
) -> None:
"""D8: the same walk over two projects, whose issue numbers collide."""
sandbox = make_world(ssh_host, tmp_path / "sandbox")
other = make_world(
ssh_host, tmp_path / "other", store=sandbox["store"], repo_name="don/other"
)
walk = Walk({"sandbox": sandbox, "other": other}, seed)
for _ in range(STEPS):
walk.step()
walk.check()