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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user