Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
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:
1 parent
4ecff19933
commit
064d9bf554
17 files changed
+1139
-315
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user