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

+4
View File
@@ -48,6 +48,10 @@ class FakeGitea:
self.assigner = assigner
self.comments: list[tuple[int, str]] = []
self.closed: list[int] = []
self.deps: dict[int, list[int]] = {}
def open_dependencies(self, repo: str, index: int) -> list[str]:
return [f"{repo}#{n}" for n in self.deps.get(index, []) if n not in self.closed]
def assigned_issues(self, repo: str, assignee: str) -> list[Issue]:
return [i for i in self.issues if i.number not in self.closed]
+35 -10
View File
@@ -58,11 +58,19 @@ class Forge:
self.notes: dict[int, list[ReviewNote]] = {}
self.posted: list[tuple[int, str]] = []
self.closed_issues: list[int] = []
self.issue = Issue(14, "Add count_words", "count words", "don", ())
self.issues = [Issue(14, "Add count_words", "count words", "don", ())]
self.deps: dict[int, list[int]] = {} # issue -> the issues it depends on
# intake
def assigned_issues(self, repo: str, assignee: str) -> list[Issue]:
return [] if self.issue.number in self.closed_issues else [self.issue]
return [i for i in self.issues if i.number not in self.closed_issues]
def open_dependencies(self, repo: str, index: int) -> list[str]:
return [
f"{repo}#{n}"
for n in self.deps.get(index, [])
if n not in self.closed_issues
]
def assigned_by(self, repo: str, index: int, assignee: str) -> str:
return "don"
@@ -109,14 +117,31 @@ class Forge:
# what the operator does in Gitea
def merge(self, n: int) -> None:
head = self.prs[n]["head"]
git(
"--git-dir",
str(self.bare),
"update-ref",
"refs/heads/main",
f"refs/heads/{head}",
)
head = f"refs/heads/{self.prs[n]['head']}"
bare = ["--git-dir", str(self.bare)]
merged = head
if subprocess.run(
["git", *bare, "merge-base", "--is-ancestor", "refs/heads/main", head],
check=False,
).returncode:
# Main moved on (another item's PR): a real merge commit.
tree = git(*bare, "merge-tree", "--write-tree", "refs/heads/main", head)
merged = git(
*bare,
"-c",
"user.name=g",
"-c",
"user.email=g@t",
"commit-tree",
tree,
"-p",
"refs/heads/main",
"-p",
head,
"-m",
f"Merge PR {n}",
)
git(*bare, "update-ref", "refs/heads/main", merged)
self.prs[n].update(state="closed", merged=True)
def files(self, n: int) -> list[str]:
+110 -15
View File
@@ -1,9 +1,10 @@
"""specs/loop.qnt's properties, checked against the real loop (M2.1, PR D).
The Quint model checks the design; this checks the code. A seeded random
walk interleaves what the operator and Gitea can do (go-aheads, answers,
comments, merges, closes, abandon, a crash and restart) with an adaptive
scripted model, and checks every property after every step. A failure prints
walk interleaves what the operator and Gitea can do (new issues, some
depending on others, go-aheads, answers, "do #N next", comments, merges,
closes, abandon, a crash and restart) with an adaptive scripted model, and
checks every property after every step. A failure prints
the seed and the steps, so it replays exactly.
"""
@@ -19,17 +20,25 @@ from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import Fact, ToolCall
from nisse_dev.gitea import Comment
from nisse_dev.gitea import Comment, Issue
from nisse_dev.loop.agent import LoopAgent
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, WAITING, loop_of
from nisse_dev.loop.items import (
ANSWER,
BLOCKED,
GO_AHEAD,
MERGE,
RUNNING,
WAITING,
loop_of,
)
from nisse_dev.oplog import OpLog
from nisse_dev.tools import Workspace
from nisse_dev.transport import InboundMessage, InMemoryTransport
from nisse_dev.worker import InlineWorker
SEEDS = range(32)
STEPS = 20
STEPS = 30
class PolicyModel:
@@ -55,6 +64,13 @@ class PolicyModel:
return [call("restate", text=f"restatement {self.n}")]
if "wait_for_review" in offered and self.rng.random() < 0.2:
return [call("wait_for_review", note=f"waiting {self.n}")]
if self.rng.random() < 0.15:
# Half done, then a question: work a switch must park.
return [
call("start_milestone", name=f"m{self.n}"),
call("write_file", path=f"f{self.n}.py", content=f"x = {self.n}\n"),
call("ask_human", question=f"halfway {self.n}?"),
]
merged = any("merged" in t for t in facts[-6:])
if merged and self.rng.random() < 0.8:
return [call("close_item", summary=f"done {self.n}")]
@@ -71,7 +87,8 @@ class Walk:
self.rng = random.Random(seed)
self.seed = seed
self.steps: list[str] = []
self.shown_at_go: list[int] = [] # the revision on screen at each go
self.shown_at_go: list[str] = [] # item:revision on screen at each go
self.next_issue = 15
self.agent, self.transport = self._agent()
def _agent(self) -> tuple[LoopAgent, InMemoryTransport]:
@@ -105,20 +122,30 @@ class Walk:
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}
for item in open_items:
loop = loop_of(item)
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
if item and loop:
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
weights.update({"first": 1})
if loop.pr:
weights.update(
{"comment": 4, "merge": 2, "close-pr": 1, "comment-merge": 2}
)
action = self.rng.choices(list(weights), list(weights.values()))[0]
self.steps.append(action)
item = open_items[0] if open_items else None
loop = loop_of(item) if item else None
self.steps.append(f"{action}#{loop.issue}" if loop 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
forge.issues.append(Issue(n, f"task {n}", f"do task {n}", "don", ()))
live = [i.number for i in forge.assigned_issues("", "") if i.number != n]
if live and self.rng.random() < 0.5:
forge.deps[n] = [self.rng.choice(live)]
self.agent.intake()
elif action == "first" and loop:
self.agent.handle(InboundMessage(DON, f"do #{loop.issue} next"), None)
elif action == "crash":
# Volatile state is lost: a new agent over the same store.
self.agent, self.transport = self._agent()
@@ -127,7 +154,7 @@ class Walk:
elif action in ("go", "answer", "abandon") and loop:
text = {"go": "go", "answer": "an answer", "abandon": "abandon"}[action]
if action == "go" and item.position == WAITING and loop.wait == GO_AHEAD:
self.shown_at_go.append(loop.rev)
self.shown_at_go.append(f"{item.id}:{loop.rev}")
self.transport.receive(DON, text, thread=loop.thread)
elif action == "go-stale" and loop and loop.rev > 1:
# A go sent while an older revision was on screen.
@@ -185,9 +212,21 @@ class Walk:
if isinstance(f.event, Fact)
and f.event.text.startswith("go-ahead on rev-")
]
assert all(rev in self.shown_at_go for rev in gos), (
assert all(f"{item.id}:{rev}" in self.shown_at_go for rev in gos), (
f"go_binds: {gos}: {where}"
)
# 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):
assert item.position == BLOCKED or item.status == "closed", (
f"blocked_never_runs: #{loop.issue} {item.position}: {where}"
)
ran = [
e
for e in store.transcript(item.id, "driver").read()
if e.actor == "driver"
]
assert not ran, f"blocked_never_runs (driver): #{loop.issue}: {where}"
mine = [p for p in forge.prs.values() if p["head"].startswith(item.id)]
assert sum(p["state"] == "open" for p in mine) <= 1, (
f"milestone_after_merge: {where}"
@@ -199,6 +238,10 @@ class Walk:
assert not [
i for i in store.pending_intents() if i.work_item == item.id
], f"closed_items_leave_nothing (intent): {where}"
assert not [i for i in items if i.position == RUNNING], (
f"one_active (nothing left running): {where}"
)
self._parked_work_safe(where)
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"]
@@ -216,6 +259,58 @@ class Walk:
)
assert landed, f"review_fixes_land (PR {n}): {where}"
def _parked_work_safe(self, 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"]
current = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
ids = [i.id for i in self.items() if i.status == "open"]
for line in _git(
root, "for-each-ref", "--format=%(refname:short)", "refs/heads"
).splitlines():
if line == current or not any(line.startswith(f"{i}-") for i in ids):
continue
local = _git(root, "rev-parse", line)
pushed = subprocess.run(
[
"git",
"--git-dir",
str(bare),
"cat-file",
"-e",
f"{local}^{{commit}}",
],
capture_output=True,
check=False,
)
assert pushed.returncode == 0, (
f"parked_work_safe: {line} isn't pushed: {where}"
)
# 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":
continue
written = {
str(e.event.arguments.get("path"))
for e in self.world["store"].transcript(item.id, "driver").read()
if isinstance(e.event, ToolCall) and e.event.tool == "write_file"
}
here = current.startswith(f"{item.id}-")
for path in written:
if here and (root / path).exists():
continue
history = _git(bare, "log", "--all", "--format=%H", "--", path)
assert history, f"parked_work_safe: {path} of #{item.id} lost: {where}"
def _git(root: Path, *args: str) -> str:
where = ["--git-dir", str(root)] if root.suffix == ".git" else ["-C", str(root)]
return subprocess.run(
["git", *where, *args], capture_output=True, text=True, check=True
).stdout.strip()
class _Null:
def write(self, _: str) -> None: ...
+192
View File
@@ -0,0 +1,192 @@
"""The queue in the new loop (M2.2): several items, parking and resuming on
switch, the operator's order, and items blocked by Gitea dependencies (L1,
D4). Real git on the throwaway build host; a fake Gitea."""
from typing import Any
from test_loop_milestones import DON, Forge, ScriptedModel, call, git, loop_agent
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_dev.gitea import Comment, Issue
from nisse_dev.loop.items import (
ANSWER,
BLOCKED,
GO_AHEAD,
MERGE,
READY,
WAITING,
item_for_issue,
loop_of,
)
from nisse_dev.transport import InboundMessage, InMemoryTransport
def two_issues(forge: Forge) -> None:
forge.issues.append(Issue(15, "Add count_lines", "count lines", "don", ()))
def item(world: dict[str, Any], issue: int) -> Any:
found = item_for_issue(world["store"], issue)
assert found is not None
return found
def say(
transport: InMemoryTransport, world: dict[str, Any], issue: int, text: str
) -> None:
transport.receive(DON, text, thread=loop_of(item(world, issue)).thread)
def restated(transport: InMemoryTransport) -> list[str]:
"""The threads restatements went up in, in order."""
return [m.thread for m in transport.outbound if m.text.startswith("rev-1:")]
def test_switching_parks_the_work_and_resuming_finds_it(
world: dict[str, Any],
) -> None:
forge: Forge = world["forge"]
two_issues(forge)
model = ScriptedModel(
[call("restate", text="count_words")], # 14
[call("restate", text="count_lines")], # 15
[ # 14, after its go: a PR
call("start_milestone", name="count_words"),
call("write_file", path="words.py", content="W = 1\n"),
call("finish_milestone", summary="count_words"),
],
[ # 15, after its go: half done, then a question
call("start_milestone", name="count_lines"),
call("write_file", path="lines.py", content="L = 1\n"),
call("ask_human", question="one per line, or per paragraph?"),
],
[ # 14, a review comment
call("write_file", path="words.py", content="W = 2\n"),
call("finish_milestone", summary="review fix"),
],
[ # 15, answered: back where it was
call("run_command", command="cat lines.py"),
call("finish_milestone", summary="count_lines"),
],
)
agent, transport = loop_agent(world, model)
agent.intake()
assert [loop_of(item(world, n)).wait for n in (14, 15)] == [GO_AHEAD, GO_AHEAD]
# The second item is acknowledged with its place in line.
assert any("#15" in m.text and "1 ahead" in m.text for m in transport.outbound)
say(transport, world, 14, "go")
assert loop_of(item(world, 14)).wait == MERGE
a_branch = loop_of(item(world, 14)).branch
say(transport, world, 15, "go")
assert loop_of(item(world, 15)).wait == ANSWER
# 15 was prepared while 14's branch was on the workspace: kept, not set aside.
assert not any("Set aside" in m.text for m in transport.outbound)
assert a_branch in world["repo"].local_branches()
[pr] = forge.prs
forge.comments[pr] = [Comment(7, "don", "make W 2", "t")]
agent.watch()
agent._schedule()
# Switching to 14 parked 15: its half-done work is committed and pushed.
b_branch = loop_of(item(world, 15)).branch
pushed = git(
"--git-dir", str(world["bare"]), "show", f"refs/heads/{b_branch}:lines.py"
)
assert pushed == "L = 1"
# 14's fix went to its own branch, without 15's work.
assert loop_of(item(world, 14)).wait == MERGE
files = git("--git-dir", str(world["bare"]), "ls-tree", "--name-only", a_branch)
assert "words.py" in files.split() and "lines.py" not in files.split()
assert "W = 2" in git(
"--git-dir", str(world["bare"]), "show", f"refs/heads/{a_branch}:words.py"
)
say(transport, world, 15, "one per line")
assert world["repo"].current_branch() == b_branch
assert (world["repo"].root / "lines.py").read_text() == "L = 1\n"
assert loop_of(item(world, 15)).wait == MERGE
assert len(forge.prs) == 2
def test_a_blocked_item_waits_for_its_dependency_to_close(
world: dict[str, Any],
) -> None:
forge: Forge = world["forge"]
two_issues(forge)
forge.deps[15] = [14]
model = ScriptedModel(
[call("restate", text="count_words")],
[call("restate", text="count_lines")],
)
agent, transport = loop_agent(world, model)
agent.intake()
assert item(world, 15).position == BLOCKED
assert loop_of(item(world, 15)).blocked_by == ("don/sandbox#14",)
assert any(
"#15" in m.text and "blocked by don/sandbox#14" in m.text
for m in transport.outbound
)
assert "#15 Add count_lines: blocked by don/sandbox#14" in agent.status()
# Talking to it doesn't start it.
say(transport, world, 15, "go")
assert item(world, 15).position == BLOCKED
assert "blocked" in transport.outbound[-1].text
forge.closed_issues.append(14)
agent.intake()
assert (item(world, 15).position, loop_of(item(world, 15)).wait) == (
WAITING,
GO_AHEAD,
)
assert loop_of(item(world, 15)).blocked_by == ()
def test_an_item_that_gains_a_dependency_is_blocked(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
two_issues(forge)
model = ScriptedModel([call("restate", text="count_words")])
agent, _ = loop_agent(world, model)
agent._running = True # hold the queue: both stay ready
agent.intake()
assert item(world, 15).position == READY
forge.deps[15] = [14]
agent.check_blocked()
assert item(world, 15).position == BLOCKED
agent._running = False
agent._schedule() # 14 restates; 15 stays blocked
assert item(world, 15).position == BLOCKED
assert loop_of(item(world, 14)).wait == GO_AHEAD
def test_the_operators_order_comes_before_the_oldest(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
two_issues(forge)
forge.issues.append(Issue(16, "Add count_chars", "count chars", "don", ()))
forge.issues.append(Issue(17, "Add count_vowels", "count vowels", "don", ()))
for n in (15, 16, 17):
forge.deps[n] = [14]
model = ScriptedModel(*[[call("restate", text=f"r{n}")] for n in range(4)])
agent, transport = loop_agent(world, model)
agent.intake() # 14 runs; the rest wait on it
transport.receive(DON, "Do #17 next.")
assert "#17 goes first once it's unblocked" in transport.outbound[-1].text
transport.receive(DON, "do #99 next")
assert "#99 isn't one of my open work items" in transport.outbound[-1].text
forge.closed_issues.append(14)
agent.intake()
threads = {loop_of(item(world, n)).thread: n for n in (14, 15, 16, 17)}
assert [threads[t] for t in restated(transport)] == [14, 17, 15, 16]
def test_status_shows_the_line(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
two_issues(forge)
agent, _ = loop_agent(world, ScriptedModel())
agent._running = True # hold the queue
agent.intake()
assert "#14 Add count_words: ready, 1 of 2 in line" in agent.status()
assert "#15 Add count_lines: ready, 2 of 2 in line" in agent.status()
agent.handle(InboundMessage(DON, "do #15 next"), None)
assert "#15 Add count_lines: ready, 1 of 2 in line" in agent.status()