The queue: parking, the operator's order, blocked items (M2.2)
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:
dongarandClaude Opus 5.5 committed 2026-10-04 13:34:09 +00:00
1 parent d504588a57
commit cceb30c729
11 files changed
+657 -85

No files matched your search

+119 -41
View File
@@ -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)