Fixes from the rest of the live session
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m13s
CI / e2e (pull_request) Successful in 3m22s

- A near miss for abandon ("abort", "cancel", "drop it", ...) in an item's
  thread is asked about, and changes nothing.
- The driver is told only the operator stops or closes an item, so it
  doesn't claim to have done either.
- An issue assigned again after its item closed starts a new item that
  notes the one before (Gitea's timeline gives the assignment time);
  item_for_issue returns the latest item.
- Abandoning an issue Gitea won't close because of open dependencies says
  so, naming them.
- An agreed item isn't offered restate.

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-08 02:16:51 +00:00
1 parent 613a2a7f72
commit 0064ec7696
9 files changed
+242 -14

No files matched your search

+25
View File
@@ -261,6 +261,31 @@ class GiteaClient:
who = (event.get("user") or {}).get("login")
return who
def assigned_at(self, repo: str, index: int, assignee: str) -> str | None:
"""When the issue was last assigned to ``assignee`` (ISO 8601), from
its timeline, oldest first and paged to the end; None if the
timeline doesn't say."""
when = None
for page in range(1, MAX_PR_PAGES + 1):
events = (
self._request(
"GET",
f"/repos/{repo}/issues/{index}/timeline",
params={"limit": PR_PAGE_SIZE, "page": page},
)
or []
)
for event in events:
if (
event.get("type") == "assignees"
and not event.get("removed_assignee")
and (event.get("assignee") or {}).get("login") == assignee
):
when = event.get("created_at")
if len(events) < PR_PAGE_SIZE:
break
return when
def repository(self, repo: str) -> dict[str, Any]:
"""The repository as this account sees it, permissions included."""
return dict(self._request("GET", f"/repos/{repo}"))
+77 -5
View File
@@ -22,6 +22,7 @@ import time
import uuid
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import (
@@ -87,6 +88,19 @@ _NEXT = re.compile(rf"(?:do|work on) {_ISSUE} (?:next|first|now)")
_NAMED = re.compile(r"(go|approve|abandon|stop) (?:([a-z0-9][a-z0-9-]*))?#(\d+)")
# At top level these need an item; without one they are ambiguous (L5).
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"})
# Near-misses for abandon: asked about, never taken for it or for a reply.
_NEAR_ABANDON = frozenset(
{
"abort",
"abort it",
"cancel",
"cancel it",
"drop it",
"quit",
"kill it",
"scrap it",
}
)
BUG_GRACE_S = 600.0
GENERAL_IDLE_S = 86400.0
PROGRESS_EVERY_S = 60.0 # progress in prose at a readable rate (L5, #42)
@@ -113,6 +127,9 @@ If the request is unclear, ask_human instead of guessing: a restatement asks
no questions, so the operator can agree to it as it stands. At any point,
asking is better than guessing.
Only the operator stops or closes an item (their stop and abandon commands):
never say you have stopped or closed anything yourself.
Before each group of tool calls, say in a sentence what you're doing and why:
the operator sees it as your progress.
@@ -504,7 +521,8 @@ class LoopAgent:
self._oplog.emit("loop.intake_unavailable", cause=str(exc))
return
for issue in issues:
if item_for_issue(self._store, issue.number, project.name) is not None:
before = item_for_issue(self._store, issue.number, project.name)
if before is not None and not self._assigned_again(project, issue, before):
continue
if _MARKER.search(issue.body or ""):
continue # D1: a record of work started in conversation
@@ -521,7 +539,7 @@ class LoopAgent:
except GiteaError as exc:
self._oplog.emit("loop.intake_unavailable", cause=str(exc))
continue # taken on the next round
self._open(project, issue, tuple(blocked_by))
self._open(project, issue, tuple(blocked_by), follows=before)
# -- PR review and merge (D4, D5) -------------------------------------------
@@ -689,8 +707,24 @@ class LoopAgent:
return False
return who in self._gitea_operators
def _assigned_again(self, project: Project, issue: Issue, before: WorkItem) -> bool:
"""D1: an issue is taken once, and again only when it's assigned to
the agent anew after its last item closed (abandoned, say, then
given back): never on the same assignment twice."""
if before.status == "open" or not before.closed_at:
return False
try:
when = self._gitea.assigned_at(project.repo, issue.number, self._account)
except GiteaError:
return False
return bool(when) and _instant(when) > _instant(before.closed_at)
def _open(
self, project: Project, issue: Issue, blocked_by: tuple[str, ...]
self,
project: Project,
issue: Issue,
blocked_by: tuple[str, ...],
follows: WorkItem | None = None,
) -> None:
item_id = f"{project.name}-{issue.number}-{uuid.uuid4().hex[:6]}"
cause = f"assigned {project.repo}#{issue.number}"
@@ -708,6 +742,7 @@ class LoopAgent:
issue=issue.number,
blocked_by=blocked_by,
labels=labels,
follows=follows.id if follows else "",
)
self._threads[(project.name, issue.number)] = root
self._store.open_work_item(
@@ -729,6 +764,12 @@ class LoopAgent:
if project.labels
else ""
)
+ (
f"; assigned again after its earlier item {follows.id} closed "
f"({follows.outcome})"
if follows
else ""
)
)
)
text = f"{issue.title}\n\n{issue.body}".strip()
@@ -830,6 +871,18 @@ class LoopAgent:
elif not self._store.active_item():
self._transport.post("Nothing is running.", thread=msg.thread)
return
if command in _NEAR_ABANDON:
# L5: a near-miss for a command is asked about, never read as a
# reply (the driver can't stop or close anything itself).
self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
self._transport.post(
f"Did you mean `abandon`? That closes {self._label(item)} and its "
"issue; say abandon to confirm. (`stop` ends a run in progress.)",
thread=loop_of(item).thread,
)
return
if command in ("status", "details"):
# About the item, not a reply to it: nothing changes, a
# countdown runs on (L5). Recorded at the agent's level.
@@ -1666,7 +1719,9 @@ class LoopAgent:
loop = replace(loop, prepared=True)
save(self._store, item.id, RUNNING, loop, cause="driver run")
# D1: a `discuss` item talks before it restates.
hold = "discuss" in loop.labels and not loop.talked and not loop.started
hold = (
"discuss" in loop.labels and not loop.talked
) or loop.started # agreed: a restatement is behind it (D1)
if loop.question:
hold = True # nothing but answering (below)
tools = [
@@ -2124,8 +2179,19 @@ class LoopAgent:
self._gitea.post_comment(project.repo, loop.issue, f"{outcome}: {note}")
self._gitea.close_issue(project.repo, loop.issue)
except GiteaError as exc:
try:
blockers = self._gitea.open_dependencies(project.repo, loop.issue)
except GiteaError:
blockers = []
why = (
f"Gitea won't close an issue while it depends on open ones "
f"({', '.join(blockers)}): close it when they're done, or "
"remove the dependency"
if blockers
else str(exc)
)
self._transport.post(
f"closed here, but could not close #{loop.issue}: {exc}",
f"closed here, but could not close #{loop.issue}: {why}",
thread=loop.thread,
)
if outcome == "abandoned":
@@ -2133,3 +2199,9 @@ class LoopAgent:
def _fact(self, item: WorkItem, text: str) -> None:
self._store.transcript(item.id, "orchestrator").append(Fact(text=text))
def _instant(stamp: str) -> datetime:
"""An ISO 8601 time from Gitea or the state store, comparable either way."""
when = datetime.fromisoformat(stamp)
return when if when.tzinfo else when.replace(tzinfo=UTC)
+9 -6
View File
@@ -108,9 +108,12 @@ def item_for_thread(
def item_for_issue(
store: StateStore, issue: int, project: str | None = None
) -> WorkItem | None:
"""Any item, open or closed, recorded by this issue (in ``project``,
when given): intake never takes an issue twice (D1)."""
for item in store.work_items():
if loop_of(item).issue == issue and project in (None, item.scope):
return item
return None
"""The latest item, open or closed, recorded by this issue (in
``project``, when given): intake takes an issue once, and again only on a
fresh assignment after its item closed (D1)."""
found = [
item
for item in store.work_items()
if loop_of(item).issue == issue and project in (None, item.scope)
]
return found[-1] if found else None