Fixes from the rest of the live session #122

Merged
dongar merged 1 commits from live-findings into main 2026-10-08 02:36:52 +00:00
9 changed files with 242 additions and 14 deletions
Showing only changes of commit 0064ec7696 - Show all commits

No files matched your search

+3
View File
@@ -569,6 +569,9 @@ Work arrives as Gitea issues or conversation, and leaves as reviewed PRs.
is. The agent acknowledges it ("queued myproject#14, 1 ahead"). Your issue is the is. The agent acknowledges it ("queued myproject#14, 1 ahead"). Your issue is the
item's record. Only issues filed or assigned by allowlisted operators count (L11). item's record. Only issues filed or assigned by allowlisted operators count (L11).
- **Unassigned issues are backlog,** never work, though the agent will discuss them. - **Unassigned issues are backlog,** never work, though the agent will discuss them.
- **An issue assigned again after its item closed is a new item,** which notes the
one before it; an issue whose item is still open, or whose assignment predates
the close, stays with that item.
- **Open Gitea dependencies make an issue blocked** (L1). - **Open Gitea dependencies make an issue blocked** (L1).
- **Work that starts in conversation gets a record** once it is work on a project, - **Work that starts in conversation gets a record** once it is work on a project,
not at its first message: the transcript already keeps the conversation. The not at its first message: the transcript already keeps the conversation. The
+25
View File
@@ -261,6 +261,31 @@ class GiteaClient:
who = (event.get("user") or {}).get("login") who = (event.get("user") or {}).get("login")
return who 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]: def repository(self, repo: str) -> dict[str, Any]:
"""The repository as this account sees it, permissions included.""" """The repository as this account sees it, permissions included."""
return dict(self._request("GET", f"/repos/{repo}")) return dict(self._request("GET", f"/repos/{repo}"))
+77 -5
View File
@@ -22,6 +22,7 @@ import time
import uuid import uuid
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import UTC, datetime
from nisse_dev.cancel import Cancel from nisse_dev.cancel import Cancel
from nisse_dev.canonical import ( 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+)") _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). # At top level these need an item; without one they are ambiguous (L5).
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"}) _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 BUG_GRACE_S = 600.0
GENERAL_IDLE_S = 86400.0 GENERAL_IDLE_S = 86400.0
PROGRESS_EVERY_S = 60.0 # progress in prose at a readable rate (L5, #42) 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, no questions, so the operator can agree to it as it stands. At any point,
asking is better than guessing. 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: Before each group of tool calls, say in a sentence what you're doing and why:
the operator sees it as your progress. the operator sees it as your progress.
@@ -504,7 +521,8 @@ class LoopAgent:
self._oplog.emit("loop.intake_unavailable", cause=str(exc)) self._oplog.emit("loop.intake_unavailable", cause=str(exc))
return return
for issue in issues: 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 continue
if _MARKER.search(issue.body or ""): if _MARKER.search(issue.body or ""):
continue # D1: a record of work started in conversation continue # D1: a record of work started in conversation
@@ -521,7 +539,7 @@ class LoopAgent:
except GiteaError as exc: except GiteaError as exc:
self._oplog.emit("loop.intake_unavailable", cause=str(exc)) self._oplog.emit("loop.intake_unavailable", cause=str(exc))
continue # taken on the next round 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) ------------------------------------------- # -- PR review and merge (D4, D5) -------------------------------------------
@@ -689,8 +707,24 @@ class LoopAgent:
return False return False
return who in self._gitea_operators 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( 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: ) -> None:
item_id = f"{project.name}-{issue.number}-{uuid.uuid4().hex[:6]}" item_id = f"{project.name}-{issue.number}-{uuid.uuid4().hex[:6]}"
cause = f"assigned {project.repo}#{issue.number}" cause = f"assigned {project.repo}#{issue.number}"
@@ -708,6 +742,7 @@ class LoopAgent:
issue=issue.number, issue=issue.number,
blocked_by=blocked_by, blocked_by=blocked_by,
labels=labels, labels=labels,
follows=follows.id if follows else "",
) )
self._threads[(project.name, issue.number)] = root self._threads[(project.name, issue.number)] = root
self._store.open_work_item( self._store.open_work_item(
@@ -729,6 +764,12 @@ class LoopAgent:
if project.labels if project.labels
else "" 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() text = f"{issue.title}\n\n{issue.body}".strip()
@@ -830,6 +871,18 @@ class LoopAgent:
elif not self._store.active_item(): elif not self._store.active_item():
self._transport.post("Nothing is running.", thread=msg.thread) self._transport.post("Nothing is running.", thread=msg.thread)
return 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"): if command in ("status", "details"):
# About the item, not a reply to it: nothing changes, a # About the item, not a reply to it: nothing changes, a
# countdown runs on (L5). Recorded at the agent's level. # countdown runs on (L5). Recorded at the agent's level.
@@ -1666,7 +1719,9 @@ class LoopAgent:
loop = replace(loop, prepared=True) loop = replace(loop, prepared=True)
save(self._store, item.id, RUNNING, loop, cause="driver run") save(self._store, item.id, RUNNING, loop, cause="driver run")
# D1: a `discuss` item talks before it restates. # 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: if loop.question:
hold = True # nothing but answering (below) hold = True # nothing but answering (below)
tools = [ tools = [
@@ -2124,8 +2179,19 @@ class LoopAgent:
self._gitea.post_comment(project.repo, loop.issue, f"{outcome}: {note}") self._gitea.post_comment(project.repo, loop.issue, f"{outcome}: {note}")
self._gitea.close_issue(project.repo, loop.issue) self._gitea.close_issue(project.repo, loop.issue)
except GiteaError as exc: 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( 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, thread=loop.thread,
) )
if outcome == "abandoned": if outcome == "abandoned":
@@ -2133,3 +2199,9 @@ class LoopAgent:
def _fact(self, item: WorkItem, text: str) -> None: def _fact(self, item: WorkItem, text: str) -> None:
self._store.transcript(item.id, "orchestrator").append(Fact(text=text)) 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( def item_for_issue(
store: StateStore, issue: int, project: str | None = None store: StateStore, issue: int, project: str | None = None
) -> WorkItem | None: ) -> WorkItem | None:
"""Any item, open or closed, recorded by this issue (in ``project``, """The latest item, open or closed, recorded by this issue (in
when given): intake never takes an issue twice (D1).""" ``project``, when given): intake takes an issue once, and again only on a
for item in store.work_items(): fresh assignment after its item closed (D1)."""
if loop_of(item).issue == issue and project in (None, item.scope): found = [
return item item
return None 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
+27
View File
@@ -229,3 +229,30 @@ def test_closing_comments_first_then_closes_issue_and_pr() -> None:
("POST", "/api/v1/repos/o/r/issues/20/comments", {"body": "abandoned by don"}), ("POST", "/api/v1/repos/o/r/issues/20/comments", {"body": "abandoned by don"}),
("PATCH", "/api/v1/repos/o/r/pulls/20", {"state": "closed"}), ("PATCH", "/api/v1/repos/o/r/pulls/20", {"state": "closed"}),
] ]
def test_assigned_at_is_the_last_assignment_across_pages() -> None:
def assigned(login: str, at: str, removed: bool = False) -> dict:
return {
"type": "assignees",
"assignee": {"login": login},
"removed_assignee": removed,
"created_at": at,
}
filler = {"type": "comment", "created_at": "t0"}
pages = {
1: [assigned("dev-agent", "t1")] + [filler] * (PR_PAGE_SIZE - 1),
2: [
assigned("dev-agent", "t2", removed=True),
assigned("someone", "t3"),
assigned("dev-agent", "t4"),
],
}
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/repos/o/r/issues/6/timeline"
return httpx.Response(200, json=pages[int(request.url.params["page"])])
assert client(handler).assigned_at("o/r", 6, "dev-agent") == "t4"
assert client(handler).assigned_at("o/r", 6, "nobody") is None
+4
View File
@@ -49,6 +49,7 @@ class FakeGitea:
self.comments: list[tuple[int, str]] = [] self.comments: list[tuple[int, str]] = []
self.closed: list[int] = [] self.closed: list[int] = []
self.deps: dict[int, list[int]] = {} self.deps: dict[int, list[int]] = {}
self.reassigned: dict[int, str] = {} # issue -> when assigned again
def create_issue(self, repo: str, title: str, body: str) -> int: def create_issue(self, repo: str, title: str, body: str) -> int:
n = max([i.number for i in self.issues] + [0]) + 1 n = max([i.number for i in self.issues] + [0]) + 1
@@ -67,6 +68,9 @@ class FakeGitea:
def assigned_by(self, repo: str, index: int, assignee: str) -> str: def assigned_by(self, repo: str, index: int, assignee: str) -> str:
return self.assigner return self.assigner
def assigned_at(self, repo: str, index: int, assignee: str) -> str | None:
return self.reassigned.get(index)
def post_comment(self, repo: str, index: int, body: str) -> None: def post_comment(self, repo: str, index: int, body: str) -> None:
self.comments.append((index, body)) self.comments.append((index, body))
+86 -2
View File
@@ -1,14 +1,16 @@
"""What the first live session of the new loop found (2026-10-07), each as a """What the first live session of the new loop found (2026-10-07), each as a
test: a request ending on the assistant's side (refused by models without test: a request ending on the assistant's side (refused by models without
prefill: the driver's prose nudge, a helper's first request), `status` in an prefill: the driver's prose nudge, a helper's first request), `status` in an
item's thread taken as a reply, and no way for the driver to answer a remark. item's thread taken as a reply, no way for the driver to answer a remark;
later, near-miss commands, the driver claiming a stop, a reassigned issue
ignored, an unexplained failed abandon and restating an agreed item.
""" """
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from model_contract import assert_renderable from model_contract import assert_renderable
from test_loop import RESTATED, FakeGitea, Harness, ScriptedModel, call, issue from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
from test_loop_help import ROLES, hand_back from test_loop_help import ROLES, hand_back
from test_loop_milestones import RESTATE, Forge, loop_agent, say from test_loop_milestones import RESTATE, Forge, loop_agent, say
from test_loop_milestones import item as the_item from test_loop_milestones import item as the_item
@@ -152,3 +154,85 @@ def test_a_review_comment_can_be_answered_without_a_fix(
assert (loop.wait, loop.pr) == (MERGE, pr) assert (loop.wait, loop.pr) == (MERGE, pr)
assert forge.posted[-1][0] == pr # answered on the PR, where asked assert forge.posted[-1][0] == pr # answered on the PR, where asked
assert "It's a constant on purpose" in forge.posted[-1][1] assert "It's a constant on purpose" in forge.posted[-1][1]
# --- the session's later findings ----------------------------------------------
def test_a_near_miss_for_abandon_is_asked_about(tmp_path: Path) -> None:
model = ScriptedModel(RESTATED) # called once only: nothing reaches it
h = Harness(tmp_path, model, FakeGitea(issue()))
h.agent.intake()
h.say("abort")
assert h.posted()[-1].startswith("Did you mean `abandon`?")
assert (h.item().status, loop_of(h.item()).wait) == ("open", GO_AHEAD)
h.say("abandon")
assert h.item().status == "closed"
def test_the_driver_is_told_it_cant_stop_or_close(tmp_path: Path) -> None:
seen: list[str] = []
class Spy(ScriptedModel):
def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def]
seen.append(system or "")
return super().complete(events, tools, model, system)
h = Harness(tmp_path, Spy(RESTATED), FakeGitea(issue()))
h.agent.intake()
assert "never say you have stopped or closed anything yourself" in seen[0]
def test_an_issue_assigned_again_after_its_item_closed_is_taken_again(
tmp_path: Path,
) -> None:
gitea = FakeGitea(issue())
h = Harness(tmp_path, ScriptedModel(RESTATED, RESTATED), gitea)
h.agent.intake()
h.say("abandon")
[old] = h.store.work_items()
gitea.closed.remove(14) # the operator reopened it
h.agent.intake()
assert len(h.store.work_items()) == 1 # the same assignment: not again
gitea.reassigned[14] = "2999-01-01T00:00:00Z" # assigned anew, later
h.agent.intake()
items = h.store.work_items()
assert len(items) == 2
new = next(i for i in items if i.id != old.id)
assert new.status == "open" and loop_of(new).follows == old.id
def test_abandoning_a_blocked_issue_says_why_it_stays_open(tmp_path: Path) -> None:
from nisse_dev.gitea import GiteaError
class Strict(FakeGitea):
def close_issue(self, repo: str, index: int) -> None:
if self.open_dependencies(repo, index):
raise GiteaError("gitea PATCH returned 500: blocked")
super().close_issue(repo, index)
gitea = Strict(issue(14), issue(15, title="other"))
gitea.deps[14] = [15]
h = Harness(tmp_path, ScriptedModel(RESTATED), gitea)
h.agent._running = True # hold the queue: only the abandon matters
h.agent.intake()
item = next(i for i in h.store.work_items() if loop_of(i).issue == 14)
h.transport.receive(DON, "abandon", thread=loop_of(item).thread)
said = " ".join(h.posted())
assert "won't close an issue while it depends on open ones" in said
assert "don/sandbox#15" in said
def test_an_agreed_item_isnt_offered_restate(world: dict[str, Any]) -> None:
offered: list[set[str]] = []
class Spy(ScriptedModel):
def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def]
offered.append({t["function"]["name"] for t in tools})
return super().complete(events, tools, model, system)
model = Spy(RESTATE, [call("ask_human", question="anything else?")])
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
assert "restate" in offered[0] and "restate" not in offered[1]
+4
View File
@@ -61,6 +61,7 @@ class Forge:
self.issues = [Issue(14, "Add count_words", "count words", "don", ())] self.issues = [Issue(14, "Add count_words", "count words", "don", ())]
self.deps: dict[int, list[int]] = {} # issue -> the issues it depends on self.deps: dict[int, list[int]] = {} # issue -> the issues it depends on
self.assigned: list[int] = [] # issues the agent assigned to itself self.assigned: list[int] = [] # issues the agent assigned to itself
self.reassigned: dict[int, str] = {} # issue -> when assigned again
# intake # intake
def assigned_issues(self, repo: str, assignee: str) -> list[Issue]: def assigned_issues(self, repo: str, assignee: str) -> list[Issue]:
@@ -84,6 +85,9 @@ class Forge:
def assigned_by(self, repo: str, index: int, assignee: str) -> str: def assigned_by(self, repo: str, index: int, assignee: str) -> str:
return "don" return "don"
def assigned_at(self, repo: str, index: int, assignee: str) -> str | None:
return self.reassigned.get(index)
# PRs # PRs
def _pr(self, n: int) -> PullRequest: def _pr(self, n: int) -> PullRequest:
p = self.prs[n] p = self.prs[n]
+7 -1
View File
@@ -400,7 +400,13 @@ class Walk:
where = f"seed {self.seed}, steps {self.steps}" where = f"seed {self.seed}, steps {self.steps}"
store = self.world["store"] store = self.world["store"]
items = self.items() items = self.items()
issues = [(i.scope, loop_of(i).issue) for i in items if loop_of(i).issue] # Open items: an issue assigned again after its item closed gets a new
# one (D1), which loop.qnt doesn't model.
issues = [
(i.scope, loop_of(i).issue)
for i in items
if loop_of(i).issue and i.status == "open"
]
assert len(issues) == len(set(issues)), f"one_item_per_issue: {where}" assert len(issues) == len(set(issues)), f"one_item_per_issue: {where}"
for item in items: for item in items:
loop = loop_of(item) loop = loop_of(item)