Fixes from the rest of the live session
- 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:
1 parent
613a2a7f72
commit
0064ec7696
9 files changed
+242
-14
No files matched your search
@@ -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}"))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user