The queue: parking, the operator's order, blocked items (M2.2)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m26s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m26s
Several work items on the new loop (L1, D4): - Switching parks the item on the workspace: its work is committed and its branch pushed; resuming checks the item's branch back out. Branches of open items are never set aside by a fresh workspace. - Next item: the operator's "do #N next" first, otherwise the oldest. - Issues with open Gitea dependencies are blocked until they close, checked at intake and on every round; status shows blocked items and the line. Tests: queue unit tests, the property walk over several issues with dependencies and a new parked_work_safe property (it catches parking disabled), and an end-to-end test with two issues where B depends on A. 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
d504588a57
commit
cceb30c729
11 files changed
+657
-85
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"""The end-to-end test on the new loop (Development Plan M2.1, PR D): the
|
||||
"""The end-to-end tests on the new loop (Development Plan M2.1, M2.2): the
|
||||
whole slice, from an issue assigned in Gitea to a merged PR and a closed work
|
||||
item, with real models, the Gitea sandbox and a real build host.
|
||||
item, and the queue, with an issue blocked on another until it closes; with
|
||||
real models, the Gitea sandbox and a real build host.
|
||||
|
||||
A scripted operator plays the operator's part: in Gitea through its own test
|
||||
account (NISSE_OPERATOR_TOKEN; an operator only in nisse-test.yaml), and in
|
||||
@@ -19,13 +20,21 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from e2e_support import base_branch, operator_gitea, task
|
||||
from e2e_support import base_branch, dependent_task, operator_gitea, task
|
||||
|
||||
from nisse_dev.config import AgentConfig
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.gitea import GiteaClient, GiteaError
|
||||
from nisse_dev.loop.build import RunningLoop, build_loop_agent
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, WAITING, loop_of
|
||||
from nisse_dev.loop.items import (
|
||||
ANSWER,
|
||||
BLOCKED,
|
||||
GO_AHEAD,
|
||||
MERGE,
|
||||
WAITING,
|
||||
item_for_issue,
|
||||
loop_of,
|
||||
)
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.e2e]
|
||||
@@ -121,20 +130,25 @@ class Operator:
|
||||
"""Intake and PR watching, as the agent's timer would do."""
|
||||
self.agent.worker.call(self.agent.loop.intake, timeout=DEADLINE_S)
|
||||
|
||||
def item(self) -> Any:
|
||||
def item(self, issue: int = 0) -> Any:
|
||||
"""The work item for ``issue``; by default the first one."""
|
||||
if issue:
|
||||
store = self.agent.store
|
||||
return self.agent.worker.call(lambda: item_for_issue(store, issue))
|
||||
items = self.agent.worker.call(self.agent.store.work_items)
|
||||
return items[0] if items else None
|
||||
|
||||
def say(self, text: str) -> None:
|
||||
self.transport.receive(OPERATOR, text, thread=loop_of(self.item()).thread)
|
||||
def say(self, text: str, issue: int = 0) -> None:
|
||||
thread = loop_of(self.item(issue)).thread
|
||||
self.transport.receive(OPERATOR, text, thread=thread)
|
||||
self.settle()
|
||||
|
||||
def drive_until(self, wait: str) -> Any:
|
||||
def drive_until(self, wait: str, issue: int = 0) -> Any:
|
||||
"""Answer whatever the agent asks until it waits for ``wait``."""
|
||||
deadline = time.monotonic() + DEADLINE_S
|
||||
answers = 0
|
||||
while time.monotonic() < deadline:
|
||||
item = self.item()
|
||||
item = self.item(issue)
|
||||
assert item is not None, "no work item"
|
||||
loop = loop_of(item)
|
||||
if item.position == WAITING and loop.wait == wait:
|
||||
@@ -142,16 +156,56 @@ class Operator:
|
||||
if item.status == "closed":
|
||||
raise AssertionError(f"closed early: {self.last()}")
|
||||
if item.position == WAITING and loop.wait == GO_AHEAD:
|
||||
self.say("go")
|
||||
self.say("go", issue)
|
||||
elif item.position == WAITING and loop.wait == ANSWER:
|
||||
answers += 1
|
||||
# Answering forever is how a stuck agent looks: give up.
|
||||
assert answers <= MAX_ANSWERS, f"stuck asking: {self.last()}"
|
||||
self.say("Use your best judgement and proceed.")
|
||||
self.say("Use your best judgement and proceed.", issue)
|
||||
else:
|
||||
self.settle()
|
||||
raise AssertionError(f"never waited for {wait}: {self.last()}")
|
||||
|
||||
def merge_and_close(self, issue: int) -> None:
|
||||
"""Approve and merge the item's PR, and see the agent close the item."""
|
||||
gitea, repo = self.agent.gitea, self.agent.config.gitea_repo
|
||||
as_operator = operator_gitea(self.agent.config.gitea_url)
|
||||
pr = loop_of(self.item(issue)).pr
|
||||
with contextlib.suppress(GiteaError): # approval may not be required
|
||||
as_operator._request(
|
||||
"POST",
|
||||
f"/repos/{repo}/pulls/{pr}/reviews",
|
||||
json={"event": "APPROVED", "body": "looks good"},
|
||||
)
|
||||
merge(as_operator, repo, pr)
|
||||
self.tick()
|
||||
deadline = time.monotonic() + DEADLINE_S
|
||||
nudges = 0
|
||||
while self.item(issue).status != "closed" and time.monotonic() < deadline:
|
||||
if loop_of(self.item(issue)).wait == ANSWER:
|
||||
nudges += 1
|
||||
assert nudges <= MAX_ANSWERS, f"never closed: {self.last()}"
|
||||
self.say("It's merged; please close the item.", issue)
|
||||
self.settle()
|
||||
assert self.item(issue).outcome == "completed"
|
||||
state = gitea._request("GET", f"/repos/{repo}/issues/{issue}")["state"]
|
||||
assert state == "closed"
|
||||
|
||||
def tidy(self, issues: list[int], prs: list[int]) -> None:
|
||||
"""Leave the sandbox tidy: whatever is still open gets closed."""
|
||||
gitea, repo = self.agent.gitea, self.agent.config.gitea_repo
|
||||
as_operator = operator_gitea(self.agent.config.gitea_url)
|
||||
for issue in issues:
|
||||
item = self.item(issue)
|
||||
if item is not None and item.status == "open":
|
||||
self.say("abandon", issue)
|
||||
with contextlib.suppress(GiteaError):
|
||||
as_operator.close_issue(repo, issue)
|
||||
for pr in prs:
|
||||
with contextlib.suppress(GiteaError):
|
||||
head = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["ref"]
|
||||
as_operator._request("DELETE", f"/repos/{repo}/branches/{head}")
|
||||
|
||||
def last(self) -> str:
|
||||
return self.transport.outbound[-1].text if self.transport.outbound else ""
|
||||
|
||||
@@ -201,34 +255,58 @@ def test_the_slice_end_to_end(
|
||||
assert marker in diff, "the pushed fix doesn't do what the comment asked"
|
||||
|
||||
# --- the operator approves and merges; the agent closes the item ---
|
||||
with contextlib.suppress(GiteaError): # approval may not be required
|
||||
as_operator._request(
|
||||
"POST",
|
||||
f"/repos/{repo}/pulls/{pr}/reviews",
|
||||
json={"event": "APPROVED", "body": "looks good"},
|
||||
)
|
||||
merge(as_operator, repo, pr)
|
||||
operator.tick()
|
||||
deadline = time.monotonic() + DEADLINE_S
|
||||
nudges = 0
|
||||
while operator.item().status != "closed" and time.monotonic() < deadline:
|
||||
if loop_of(operator.item()).wait == ANSWER:
|
||||
nudges += 1
|
||||
assert nudges <= MAX_ANSWERS, f"never closed: {operator.last()}"
|
||||
operator.say("It's merged; please close the item.")
|
||||
operator.settle()
|
||||
assert operator.item().outcome == "completed"
|
||||
state = gitea._request("GET", f"/repos/{repo}/issues/{issue}")["state"]
|
||||
assert state == "closed"
|
||||
operator.merge_and_close(issue)
|
||||
finally:
|
||||
# Leave the sandbox tidy: whatever is still open gets closed.
|
||||
item = operator.item()
|
||||
if item is not None and item.status == "open":
|
||||
operator.say("abandon")
|
||||
if issue is not None:
|
||||
with contextlib.suppress(GiteaError):
|
||||
as_operator.close_issue(repo, issue)
|
||||
if pr:
|
||||
with contextlib.suppress(GiteaError):
|
||||
head = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["ref"]
|
||||
as_operator._request("DELETE", f"/repos/{repo}/branches/{head}")
|
||||
operator.tidy([issue] if issue is not None else [], [pr] if pr else [])
|
||||
|
||||
|
||||
def test_an_issue_blocked_on_another_waits_for_it(
|
||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||
) -> None:
|
||||
"""M2.2: B depends on A in Gitea. B is blocked until A's PR merges and A
|
||||
closes; then B starts from a workspace that has A's work."""
|
||||
agent, transport, run = running
|
||||
operator = Operator(agent, transport)
|
||||
gitea, repo = agent.gitea, agent.config.gitea_repo
|
||||
as_operator = operator_gitea(agent.config.gitea_url)
|
||||
owner, name = repo.split("/")
|
||||
issues: list[int] = []
|
||||
prs: list[int] = []
|
||||
try:
|
||||
a = as_operator.create_issue(repo, f"vowels_{run}", task(run))
|
||||
issues.append(a)
|
||||
b = as_operator.create_issue(repo, f"consonants_{run}", dependent_task(run))
|
||||
issues.append(b)
|
||||
as_operator._request(
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{b}/dependencies",
|
||||
json={"owner": owner, "repo": name, "index": a},
|
||||
)
|
||||
for n in (b, a): # B first: the dependency, not the order, decides
|
||||
as_operator._request(
|
||||
"PATCH",
|
||||
f"/repos/{repo}/issues/{n}",
|
||||
json={"assignees": [agent.account]},
|
||||
)
|
||||
operator.tick()
|
||||
assert operator.item(b).position == BLOCKED
|
||||
assert loop_of(operator.item(b)).blocked_by == (f"{repo}#{a}",)
|
||||
|
||||
# --- A runs to a merged PR; B stays blocked meanwhile ---
|
||||
item = operator.drive_until(MERGE, a)
|
||||
prs.append(loop_of(item).pr)
|
||||
assert operator.item(b).position == BLOCKED
|
||||
operator.merge_and_close(a)
|
||||
|
||||
# --- A closed: B is unblocked and starts from A's work ---
|
||||
operator.tick()
|
||||
assert operator.item(b).position != BLOCKED
|
||||
item = operator.drive_until(MERGE, b)
|
||||
prs.append(loop_of(item).pr)
|
||||
files = gitea._request("GET", f"/repos/{repo}/pulls/{prs[-1]}/files")
|
||||
diff = gitea._http.get(f"/repos/{repo}/pulls/{prs[-1]}.diff").text
|
||||
assert f"consonants_{run}" in diff, [f["filename"] for f in files]
|
||||
# Built on A's merged work, so A's function isn't added again.
|
||||
assert f"\n+def vowels_{run}" not in diff
|
||||
finally:
|
||||
operator.tidy(issues, prs)
|
||||
Reference in new issue
Block a user