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

+120 -12
View File
@@ -7,11 +7,15 @@ because by the time the worker reaches it a newer one may be up (L3).
One work item at a time is active. An item runs until its driver calls a
waiting tool; it then waits on the operator, the active pointer is cleared
(a safe point, L1), and the next ready item runs. Conversation is not work:
(a safe point, L1), and the next ready item runs: the operator's order when
given ("do #14 next"), otherwise the oldest. Switching parks the item that was
on the workspace: its work is committed and its branch pushed (D4). An issue
with open Gitea dependencies is blocked until they close. Conversation is not work:
a message is recorded and changes its item's state, but never interrupts a
run (L1).
"""
import re
import threading
import uuid
from collections.abc import Callable, Iterable
@@ -24,6 +28,7 @@ from nisse_dev.gitea import GiteaClient, GiteaError, Issue
from nisse_dev.loop.driver import Driver, RunOutcome
from nisse_dev.loop.items import (
ANSWER,
BLOCKED,
CLOSED,
GO_AHEAD,
MERGE,
@@ -53,6 +58,7 @@ from nisse_dev.worker import Worker
INTAKE_INTERVAL_S = 30.0
_GO = frozenset({"go", "approve"})
_NEXT = re.compile(r"(?:do|work on) #?(\d+) (?:next|first|now)")
# Before the go-ahead the driver may look around (read, list, run a command
# to reproduce a bug) but not change anything (D1); after it, everything.
@@ -235,8 +241,14 @@ class LoopAgent:
issue=issue.number,
)
continue
self._open(issue)
try:
blocked_by = self._gitea.open_dependencies(self._repo, issue.number)
except GiteaError as exc:
self._oplog.emit("loop.intake_unavailable", cause=str(exc))
continue # taken on the next round
self._open(issue, tuple(blocked_by))
self.watch()
self.check_blocked()
self._schedule()
# -- PR review and merge (D4, D5) -------------------------------------------
@@ -308,6 +320,40 @@ class LoopAgent:
loop = replace(loop, wait="", note="")
save(self._store, item.id, position, loop, cause="PR update")
def check_blocked(self) -> None:
"""L1: an item that hasn't started running yet is blocked while its
Gitea dependencies are open, and ready once they have all closed.
Only ready and blocked items are checked: the others are mid-work or
waiting on the operator, and an agent that finds a blocker mid-work
asks before recording it."""
for item in self._store.work_items("open"):
if item.position not in (READY, BLOCKED):
continue
loop = loop_of(item)
issue = loop.issue
try:
blocked_by = tuple(self._gitea.open_dependencies(self._repo, issue))
except GiteaError as exc:
self._oplog.emit("loop.watch_unavailable", cause=str(exc))
continue # as it was, until Gitea answers
position = BLOCKED if blocked_by else READY
if position == item.position and blocked_by == loop.blocked_by:
continue
if position == READY:
said = f"#{issue} is unblocked: its dependencies have closed."
else:
said = f"#{issue} is blocked by {', '.join(blocked_by)}."
self._fact(item, said)
if position != item.position:
self._transport.post(said, thread=loop.thread)
save(
self._store,
item.id,
position,
replace(loop, blocked_by=blocked_by),
cause=said,
)
def _from_an_operator(self, issue: Issue) -> bool:
"""L11: only an operator's issue, or one an operator assigned, is
an instruction."""
@@ -319,18 +365,22 @@ class LoopAgent:
return False
return who in self._gitea_operators
def _open(self, issue: Issue) -> None:
def _open(self, issue: Issue, blocked_by: tuple[str, ...]) -> None:
item_id = f"{self._project}-{issue.number}-{uuid.uuid4().hex[:6]}"
cause = f"assigned {self._repo}#{issue.number}"
ahead = len(self._store.work_items("open"))
where = f", {ahead} ahead" if ahead else ""
if blocked_by:
where += f"; blocked by {', '.join(blocked_by)}"
root = self._transport.post(
f"Queued {self._repo}#{issue.number}: {issue.title}"
f"Queued {self._repo}#{issue.number}: {issue.title}{where}"
)
loop = Loop(thread=root, issue=issue.number)
loop = Loop(thread=root, issue=issue.number, blocked_by=blocked_by)
self._store.open_work_item(
item_id,
title=issue.title,
scope=self._project,
position=READY,
position=BLOCKED if blocked_by else READY,
data={"loop": loop.to_data(), "roles": self._roles},
cause=cause,
)
@@ -370,6 +420,16 @@ class LoopAgent:
item = item_for_thread(self._store, msg.thread) if msg.thread else None
text = (msg.original or msg.text).strip()
command = command_form(msg.text)
order = _NEXT.fullmatch(command)
if order is not None:
self._store.transcript(item.id if item else None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
self._transport.post(
self._put_first(int(order.group(1))), thread=msg.thread
)
self._schedule()
return
if item is None:
self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
@@ -393,6 +453,12 @@ class LoopAgent:
item = self._store.work_item(item.id) or item
if command == "abandon":
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
elif item.position == BLOCKED:
self._transport.post(
f"#{loop.issue} is blocked by {', '.join(loop.blocked_by)}; "
"I'll start on it when they close, or when you remove them.",
thread=loop.thread,
)
elif item.position == WAITING and loop.wait == GO_AHEAD and command in _GO:
if about != loop.rev:
# L3: a go-ahead is about what was on screen when it arrived.
@@ -421,16 +487,48 @@ class LoopAgent:
)
self._schedule()
def _put_first(self, issue: int) -> str:
"""The operator's "do #N next" (L1): it runs before every other
ready item, the latest such order first. It doesn't unblock."""
items = self._store.work_items("open")
item = next((i for i in items if loop_of(i).issue == issue), None)
if item is None:
return f"#{issue} isn't one of my open work items."
first = max(loop_of(i).first for i in items) + 1
loop = replace(loop_of(item), first=first)
save(self._store, item.id, item.position, loop, cause=f"#{issue} put first")
self._fact(item, "the operator put this item first in the queue")
if item.position == BLOCKED:
return (
f"#{issue} goes first once it's unblocked "
f"(blocked by {', '.join(loop.blocked_by)})."
)
if item.position == WAITING:
return f"#{issue} goes first, but it's waiting for {loop.wait}: {loop.note}"
return f"#{issue} is next."
def _queue(self) -> list[WorkItem]:
"""Ready items in the order they run (L1): the operator's order
first, then the oldest."""
items = self._store.work_items("open") # oldest first
ready = [i for i in items if i.position == READY]
return sorted(ready, key=lambda i: -loop_of(i).first)
def status(self) -> str:
items = self._store.work_items("open")
if not items:
return f"idle: no open work items. I take issues assigned to me in {self._repo}."
queue = [i.id for i in self._queue()]
lines = []
for item in items:
loop = loop_of(item)
what = item.position
if item.position == WAITING:
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
elif item.position == BLOCKED:
what = f"blocked by {', '.join(loop.blocked_by)}"
elif item.position == READY:
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
lines.append(f"#{loop.issue} {item.title}: {what}")
return "\n".join(lines)
@@ -441,25 +539,35 @@ class LoopAgent:
if self._running:
return
while True:
ready = [i for i in self._store.work_items("open") if i.position == READY]
ready = self._queue()
if not ready:
self._store.set_active(None, cause="nothing ready")
return
self._run(ready[0]) # oldest first (L1)
self._run(ready[0])
def _run(self, item: WorkItem) -> None:
loop = loop_of(item)
self._store.set_active(item.id, cause="ready")
if not loop.prepared and self._milestones is not None:
# D3: a fresh workspace when a work item starts.
if self._milestones is not None:
# Branches of open items, parked or not, are never set aside.
keep = tuple(f"{i.id}-" for i in self._store.work_items("open"))
try:
notes = self._milestones.prepare(f"{item.id}-work")
if not loop.prepared:
# D3: a fresh workspace when a work item starts.
notes = self._milestones.prepare(f"{item.id}-work", keep=keep)
else:
# D4: back to its branch, parking whoever was there.
notes = []
self._milestones.resume(
loop.branch or f"{item.id}-work", keep=keep, mine=f"{item.id}-"
)
except Exception as exc: # noqa: BLE001 -- the item waits, not the loop
note = f"I couldn't prepare the workspace: {exc}. Reply to retry."
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
self._store.set_active(None, cause="workspace failed")
return
self._fact(item, "workspace prepared from the latest default branch")
if not loop.prepared:
self._fact(item, "workspace prepared from the latest default branch")
if notes:
said = "Set aside from the workspace: " + "; ".join(notes)
self._fact(item, said)
+5 -1
View File
@@ -13,6 +13,7 @@ from nisse_dev.state import StateStore, WorkItem
READY = "ready"
RUNNING = "running" # a driver run is under way; on boot it means interrupted
WAITING = "waiting"
BLOCKED = "blocked" # waits for its recorded dependencies to close (L1)
CLOSED = "closed"
# What a waiting item waits for.
@@ -25,7 +26,7 @@ MERGE = "merge" # the PR's review and merge (D4, D5)
class Loop:
thread: str = "" # the root of the item's Matrix thread (L5)
issue: int = 0 # its record issue (D1)
wait: str = "" # GO_AHEAD / ANSWER, when waiting
wait: str = "" # GO_AHEAD / ANSWER / MERGE, when waiting
rev: int = 0 # the latest proposal put up (L3)
started: bool = False # scope agreed: a go-ahead was given (D1)
note: str = "" # what a waiting item said it is waiting on
@@ -37,6 +38,8 @@ class Loop:
comments: int = 0 # the last PR conversation comment taken (L8)
notes: tuple[str, ...] = () # review notes taken (#69)
fixes: int = 0 # review fixes pushed, for follow-up branch names
blocked_by: tuple[str, ...] = () # open dependencies, "owner/repo#N" (L1)
first: int = 0 # the operator's "do #N next": higher runs sooner
# Where the operator last spoke (D5: answer where asked): "" for the
# Matrix thread, or the PR it came from, with the inline location and a
# little of what they said, for quoting.
@@ -51,6 +54,7 @@ class Loop:
def loop_of(item: WorkItem) -> Loop:
data = dict(item.data.get("loop", {}))
data["notes"] = tuple(data.get("notes", ()))
data["blocked_by"] = tuple(data.get("blocked_by", ()))
return Loop(**data)
+43 -2
View File
@@ -57,11 +57,14 @@ class Milestones:
# -- a work item's workspace (D3) ------------------------------------------
def prepare(self, item_branch: str) -> list[str]:
def prepare(self, item_branch: str, *, keep: tuple[str, ...] = ()) -> list[str]:
"""A fresh workspace for a work item: fetch, set aside (never
discard) whatever was left, check out a clean default branch, and put
the item on a branch of its own. Returns what was set aside."""
the item on a branch of its own. Branches starting with a ``keep``
prefix belong to open work items, which parking has pushed: they are
left alone. Returns what was set aside."""
git = self._git
self.park(keep=keep)
git.fetch(credential=self._credential)
stamp = time.strftime("%Y%m%d-%H%M%S")
notes: list[str] = []
@@ -73,6 +76,8 @@ class Milestones:
for branch in git.local_branches():
if branch == self._base or branch.startswith(f"{SET_ASIDE}/"):
continue
if keep and branch.startswith(keep):
continue
git.rename_branch(branch, f"{SET_ASIDE}/{stamp}/{branch}")
notes.append(f"branch {branch}, as {SET_ASIDE}/{stamp}/{branch}")
if self._base in git.local_branches() and self._ahead_of_origin():
@@ -83,6 +88,42 @@ class Milestones:
git.checkout_new_branch(item_branch)
return notes
# -- parking and resuming (L1, D4) -----------------------------------------
def park(self, *, keep: tuple[str, ...]) -> str | None:
"""Before another item uses the workspace: commit whatever the item
on it left, and push its branch, so nothing of a parked item lives
only on the build VM. Only a harness branch of an open item (one
starting with a ``keep`` prefix) is parked; returns its name."""
git = self._git
branch = git.current_branch()
if not keep or not branch.startswith(keep):
return None
git.commit_all("WIP: parked while another work item runs")
if git.remote_sha(self._remote, branch, credential=self._credential) != (
git.head_sha()
):
git.push(self._remote, branch, credential=self._credential)
return branch
def resume(self, branch: str, *, keep: tuple[str, ...], mine: str = "") -> None:
"""Back to a parked item: park whatever is on the workspace now, then
check out the item's branch. The local branch wins when it exists (it
can only be ahead: parking pushed it); otherwise it comes from origin.
A workspace already on one of the item's own branches (``mine``, a
prefix) is where it left off, and stays."""
git = self._git
current = git.current_branch()
if current == branch or (mine and current.startswith(mine)):
return
self.park(keep=keep)
if branch in git.local_branches():
git.checkout_existing_branch(branch)
git.reset_to(branch, branch)
else:
git.fetch(credential=self._credential)
git.reset_to(branch, f"origin/{branch}")
def _ahead_of_origin(self) -> bool:
current = self._git.current_branch()
try: