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)