From 3f54896f6519198587eca307c5f27702d2d3eb53 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Tue, 6 Oct 2026 04:26:40 +0000 Subject: [PATCH] Conversations on the new loop (M2.5, PR A) - A top-level message that isn't a command is a conversation: an item in the `general` scope (L1), threaded under the operator's message, with `reply`, `ask_human` and `propose_work` and no workspace or execution. `general` is reserved as a project name. - propose_work(project, title, summary) goes up as a revision for a go-ahead (or a plain yes). On go, under a file_issue intent (L8), the record issue is opened in that project, assigned to the agent and carrying the new item's ID; its item starts agreed, with the summary as its brief. Intake skips any issue carrying an ID (D1); boot adopts a filed issue or puts the proposal up again. - A conversation closes after tuning.loop.general_idle_s (default a day); a reply in its thread then starts a new conversation that continues it. Tests: conversation unit tests (threading, filing, the marker, an unknown project, changes, idle close and continuation, a plain yes, both crash cases, commands at top level); the walk with conversations, proposals and idle time, and records_have_one_item / conversations_do_no_work; an e2e conversation that becomes filed work. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE --- docs/Development-Plan.md | 11 + docs/Tuning-Guide.md | 10 + nisse-dev.example.yaml | 1 + nisse-dev/src/nisse_dev/config.py | 6 + nisse-dev/src/nisse_dev/gitea.py | 5 + nisse-dev/src/nisse_dev/loop/agent.py | 367 ++++++++++++++++++++- nisse-dev/src/nisse_dev/loop/build.py | 1 + nisse-dev/src/nisse_dev/loop/items.py | 22 +- nisse-dev/src/nisse_dev/loop/tools.py | 29 +- nisse-dev/tests/test_loop.py | 8 + nisse-dev/tests/test_loop_conversations.py | 239 ++++++++++++++ nisse-dev/tests/test_loop_milestones.py | 9 + nisse-dev/tests/test_loop_properties.py | 85 ++++- tests/integration/test_end_to_end_loop.py | 53 +++ 14 files changed, 811 insertions(+), 35 deletions(-) create mode 100644 nisse-dev/tests/test_loop_conversations.py diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index f64f09d..88c7d7b 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -190,6 +190,17 @@ conversation, record issues carrying their item's ID, the new-project flow and i project entry (D1, D8, #29). After M1.4. End-to-end: a small new project, in a repository the test operator creates under its own account for the run and deletes afterwards. +- **PR A, conversations — done (this PR).** + - A top-level message that isn't a command is a `general` item, threaded under + it; the driver gets `reply`, `ask_human` and `propose_work`, with no workspace. + - Agreed work (a go-ahead, or a plain yes, on the proposal) is filed under an + intent as a record issue carrying the new item's ID. Its item starts agreed, + with no second restatement. Intake skips any issue carrying an ID, and boot + settles a filing cut off by a crash. + - A conversation closes after `tuning.loop.general_idle_s` without a message; a + reply in its thread then starts a new one that continues it. +- **PR B, new projects:** the proposal, the checklist, reload-and-verify, and the + tracking issue; the e2e with a per-run repository. **M2.6 Roles and capability profiles.** *Pre-approved.* `driver`, `helper`, `expert`, `classifier` (L9); escalation triggers; delegation and `ask_expert` on the diff --git a/docs/Tuning-Guide.md b/docs/Tuning-Guide.md index 00ab9c7..3e67937 100644 --- a/docs/Tuning-Guide.md +++ b/docs/Tuning-Guide.md @@ -526,3 +526,13 @@ thread stops the countdown, and `go` starts at once. - **Deciding:** about the time it takes you to notice a message and read it when you're around. A wrong start costs only time, since the fix still comes as a PR you review. + +### `tuning.loop.general_idle_s` — default 86400 + +How long a `general` conversation (one not about a project, architecture L1) +stays open without a message. It then closes with a note in its log; a later +reply in its thread starts a new conversation linked to it. + +- **Too low:** a conversation you come back to after lunch has to be picked up + again from its summary. +- **Too high:** idle conversations pile up in `status` and in the queue. diff --git a/nisse-dev.example.yaml b/nisse-dev.example.yaml index 1ae1da8..f26046e 100644 --- a/nisse-dev.example.yaml +++ b/nisse-dev.example.yaml @@ -79,3 +79,4 @@ tuning: safety_margin: 0.1 # fraction of the window held back loop: # the new loop (loop: new) bug_grace_s: 600 # a `bug` restatement proceeds after this long unanswered + general_idle_s: 86400 # a conversation closes after this long without a message diff --git a/nisse-dev/src/nisse_dev/config.py b/nisse-dev/src/nisse_dev/config.py index 576d7d3..9bdfd0a 100644 --- a/nisse-dev/src/nisse_dev/config.py +++ b/nisse-dev/src/nisse_dev/config.py @@ -68,6 +68,7 @@ PROJECT_OPTIONAL = ( # never in a command the model chose. VISIBILITY = ("commands", "harness") _PROJECT_NAME = re.compile(r"[a-z0-9][a-z0-9-]*") +GENERAL = "general" # the scope of conversation not about a project (L1) _REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") @@ -116,6 +117,8 @@ class LoopTuning: # A restatement on a `bug` issue proceeds after this long without a # reply (D1). bug_grace_s: float = 600.0 + # A `general` conversation closes after this long without a message (L1). + general_idle_s: float = 86400.0 @dataclass(frozen=True) @@ -247,6 +250,9 @@ def _parse_projects( if not isinstance(name, str) or not _PROJECT_NAME.fullmatch(name): errors.append(f"{where}: a project name is lowercase letters, digits, -") continue + if name == GENERAL: + errors.append(f"{where}: `{GENERAL}` is the scope of conversations (L1)") + continue if not isinstance(entry, dict): errors.append(f"{where}: must be a mapping") continue diff --git a/nisse-dev/src/nisse_dev/gitea.py b/nisse-dev/src/nisse_dev/gitea.py index a84228b..04734b6 100644 --- a/nisse-dev/src/nisse_dev/gitea.py +++ b/nisse-dev/src/nisse_dev/gitea.py @@ -261,6 +261,11 @@ class GiteaClient: who = (event.get("user") or {}).get("login") return who + def assign_issue(self, repo: str, index: int, assignee: str) -> None: + self._request( + "PATCH", f"/repos/{repo}/issues/{index}", json={"assignees": [assignee]} + ) + def close_issue(self, repo: str, index: int) -> None: self._request( "PATCH", f"/repos/{repo}/issues/{index}", json={"state": "closed"} diff --git a/nisse-dev/src/nisse_dev/loop/agent.py b/nisse-dev/src/nisse_dev/loop/agent.py index 28928da..f69c278 100644 --- a/nisse-dev/src/nisse_dev/loop/agent.py +++ b/nisse-dev/src/nisse_dev/loop/agent.py @@ -24,6 +24,7 @@ from dataclasses import dataclass, replace from nisse_dev.cancel import Cancel from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage +from nisse_dev.config import GENERAL from nisse_dev.gates import command_form from nisse_dev.git import GitError from nisse_dev.gitea import GiteaClient, GiteaError, Issue @@ -31,6 +32,7 @@ from nisse_dev.loop.driver import Driver, RunOutcome from nisse_dev.loop.items import ( ANSWER, BLOCKED, + CHAT, CLOSED, GO_AHEAD, MERGE, @@ -49,6 +51,8 @@ from nisse_dev.loop.tools import ( ASK_HUMAN, CLOSE_ITEM, FINISH_MILESTONE, + PROPOSE_WORK, + REPLY, RESTATE, START_MILESTONE, WAIT_FOR_REVIEW, @@ -69,6 +73,11 @@ _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"}) BUG_GRACE_S = 600.0 +GENERAL_IDLE_S = 86400.0 +FILE_ISSUE = "file_issue" # the intent of opening a record issue (L8) +# Carried by a record issue the agent opens for work started in conversation: +# intake never takes it for new work, even before its item exists (D1). +_MARKER = re.compile(r"") # 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. @@ -111,6 +120,23 @@ Keep ARCHITECTURE.md current: a design change updates it in the same PR. {workspace}""" +CONVERSATION = """You are nisse-dev, a software agent, in a conversation with +the operator in a Matrix thread. It isn't about a project yet: it may be a +question, thoughts on feasibility or hardware, or the start of some work. + +Answer with reply; ask_human only for a question you need answered. You have +no workspace and can't run anything here; if an experiment is needed, say so. + +If the conversation turns into work on one of these projects, use +propose_work, with a summary of what was decided (not the conversation): it +is the new issue's body and the work's whole brief. + +{projects} + +Setting up a new project from here isn't available yet: if that's where the +conversation goes, say so, and that the operator can create the repository +and its project entry.""" + # What an issue's labels add to the driver's instructions (D1). LABELS = { "bug": "This issue is labelled bug. Before restating, reproduce it with " @@ -177,6 +203,7 @@ class LoopAgent: milestones: Milestones | None = None, reader: GoAheadReader | None = None, bug_grace_s: float = BUG_GRACE_S, + general_idle_s: float = GENERAL_IDLE_S, clock: Callable[[], float] = time.time, intake_interval_s: float = INTAKE_INTERVAL_S, ) -> None: @@ -196,6 +223,7 @@ class LoopAgent: self._roles = roles or {} self._reader = reader self._grace = bug_grace_s + self._idle = general_idle_s self._clock = clock self._interval = intake_interval_s # thread root -> revision on screen there, read on the transport's @@ -214,11 +242,16 @@ class LoopAgent: # -- projects (D8) ---------------------------------------------------------- def _at(self, item: WorkItem) -> Project: + if item.scope == GENERAL: + # A conversation: no repository, workspace or build VM (L1). + return Project(GENERAL, "") return self._projects[item.scope or ""] def _label(self, item: WorkItem) -> str: """How an item's issue is named to the operator: #14 with one project, sandbox#14 with more, where #14 alone could be either.""" + if item.scope == GENERAL: + return f'the conversation "{item.title}"' issue = loop_of(item).issue return f"#{issue}" if len(self._projects) == 1 else f"{item.scope}#{issue}" @@ -227,7 +260,9 @@ class LoopAgent: return [ i for i in self._store.work_items("open") - if loop_of(i).issue == issue and (project is None or i.scope == project) + if i.scope != GENERAL + and loop_of(i).issue == issue + and (project is None or i.scope == project) ] def _repos(self) -> str: @@ -273,6 +308,7 @@ class LoopAgent: work blindly: an item cut off mid-run waits on the operator.""" self._store.set_active(None, cause="boot") self._reconcile_publishing() + self._reconcile_filing() for item in self._store.work_items("open"): loop = loop_of(item) self._threads[(item.scope or "", loop.issue)] = loop.thread @@ -287,12 +323,61 @@ class LoopAgent: "to start, or reply with changes.", thread=loop.thread, ) - else: + elif loop.wait != CHAT: # a conversation just goes on self._transport.post( f"(after a restart) still waiting: {loop.note}", thread=loop.thread, ) + def _reconcile_filing(self) -> None: + """L8: a record issue cut off by a crash. An issue carrying the new + item's ID was opened: its item is started now. Otherwise nothing was + opened, and the conversation's proposal is up again.""" + for intent in self._store.pending_intents(): + if intent.kind != FILE_ISSUE or not intent.work_item: + continue + conversation = self._store.work_item(intent.work_item) + repo = str(intent.payload.get("repo", "")) + new_id = str(intent.payload.get("item", "")) + project = next((p for p in self._projects.values() if p.repo == repo), None) + if self._store.work_item(new_id) is not None: + self._store.commit_intent(intent.id, cause="its item exists") + continue + if conversation is None or project is None: + self._store.abort_intent( + intent.id, cause="conversation or project gone" + ) + continue + try: + issues = self._gitea.assigned_issues(repo, self._account) + except GiteaError: + continue # unverifiable now; settled at the next boot + filed = [ + i + for i in issues + if (m := _MARKER.search(i.body or "")) and m.group(1) == new_id + ] + if filed: + self._start_filed( + conversation, + project, + new_id, + filed[0].number, + filed[0].title, + self._latest_proposal(conversation), + ) + self._store.commit_intent( + intent.id, cause=f"adopted {repo}#{filed[0].number}" + ) + else: + self._store.abort_intent(intent.id, cause="no issue was opened") + loop = loop_of(conversation) + note = ( + "opening the issue was cut off by a restart; say go to try again." + ) + self._shown[loop.thread] = loop.rev + self._wait(conversation, replace(loop, wait=GO_AHEAD, note=note), note) + def _reconcile_publishing(self) -> None: """L8 step 2: a publish cut off by a crash is settled against Gitea. An open PR for the branch is adopted; otherwise the intent is aborted @@ -337,6 +422,7 @@ class LoopAgent: self.watch() self.check_blocked() self.check_grace() + self.check_idle() self._schedule() def _intake(self, project: Project) -> None: @@ -349,6 +435,8 @@ class LoopAgent: for issue in issues: if item_for_issue(self._store, issue.number, project.name) is not None: continue + if _MARKER.search(issue.body or ""): + continue # D1: a record of work started in conversation if not self._from_an_operator(project, issue): self._oplog.emit( "loop.intake_ignored", @@ -441,7 +529,7 @@ class LoopAgent: 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): + if item.position not in (READY, BLOCKED) or item.scope == GENERAL: continue loop = loop_of(item) repo = self._at(item).repo @@ -469,6 +557,32 @@ class LoopAgent: cause=said, ) + def check_idle(self) -> None: + """L1: a conversation left without a message for a day closes; a + later reply in its thread starts a new one, linked to it.""" + now = self._clock() + for item in self._store.work_items("open"): + loop = loop_of(item) + if ( + item.scope == GENERAL + and item.position == WAITING + and loop.idle_since + and now - loop.idle_since >= self._idle + ): + said = sum( + 1 + for e in self._store.transcript(item.id, "operator").read() + if e.actor == "operator" + ) + became = f"; it became {', '.join(loop.became)}" if loop.became else "" + self._close( + item, + loop, + "idle", + f"no message for {round(self._idle / 3600)} h, after {said} " + f"from the operator{became}", + ) + def check_grace(self) -> None: """D1: a `bug` restatement left unanswered for the grace period is taken as agreed, bound to the revision that was up all along.""" @@ -613,6 +727,11 @@ class LoopAgent: ) return [item] = found + if item is None and command not in ("status", "stop", *_NEEDS_ITEM): + # L5: any other message is a conversation, from its first message. + self._converse(msg, text) + self._schedule() + return if item is None: self._store.transcript(None, "operator").append( UserMessage(text=text, source=f"transport:{msg.sender}") @@ -621,16 +740,8 @@ class LoopAgent: self._transport.post(self.status(), thread=msg.thread) elif command in _NEEDS_ITEM: self._transport.post(self._which(command), thread=msg.thread) - elif command == "stop": - if not self._store.active_item(): - self._transport.post("Nothing is running.", thread=msg.thread) - else: - self._transport.post( - f"I take work from issues assigned to me in {self._repos()}: " - "assign one and I'll pick it up. Reply in its thread to " - "talk about it.", - thread=msg.thread, - ) + elif not self._store.active_item(): + self._transport.post("Nothing is running.", thread=msg.thread) return self._store.transcript(item.id, "operator").append( UserMessage(text=text, source=f"transport:{msg.sender}") @@ -643,6 +754,7 @@ class LoopAgent: asked_text="", grace_until=0.0, talked=True, + idle_since=self._clock(), ) if loop != loop_of(item): save(self._store, item.id, item.position, loop, cause="operator spoke") @@ -694,8 +806,53 @@ class LoopAgent: ) self._schedule() + def _converse(self, msg: InboundMessage, text: str) -> None: + """A conversation (L1): its own item in the `general` scope, in a + thread under the operator's message, or in the thread of one that + closed, which it continues.""" + thread = msg.thread or msg.event_id + before = ( + item_for_thread(self._store, thread, status=None) if msg.thread else None + ) + title = text.splitlines()[0][:60] if text else "conversation" + item_id = f"{GENERAL}-{uuid.uuid4().hex[:8]}" + loop = Loop( + thread=thread, + idle_since=self._clock(), + talked=True, + follows=before.id if before else "", + ) + self._store.open_work_item( + item_id, + title=title, + scope=GENERAL, + position=READY, + data={"loop": loop.to_data(), "roles": self._roles}, + cause="the operator started a conversation", + ) + log = self._store.transcript(item_id, "orchestrator") + if before is not None: + closing = [ + e.event.text + for e in self._store.transcript(before.id, "orchestrator").read() + if isinstance(e.event, Fact) and e.event.text.startswith("work item ") + ] + log.append( + Fact( + text=f'this continues the conversation "{before.title}" ' + f"({before.id}), " + + (closing[-1] if closing else "which is still open") + ) + ) + log.as_actor("operator").append( + UserMessage(text=text, source=f"transport:{msg.sender}") + ) + def _go(self, item: WorkItem, loop: Loop, by: str) -> None: self._fact(item, f"go-ahead on rev-{loop.rev} from {by}") + if item.scope == GENERAL: + self._file_work(item, loop) + return save( self._store, item.id, @@ -704,6 +861,102 @@ class LoopAgent: cause=f"go-ahead on rev-{loop.rev}", ) + def _file_work(self, item: WorkItem, loop: Loop) -> None: + """D1: agreed work from a conversation gets a record issue in its + project, carrying the new item's ID from the moment it exists, and + an item of its own that starts as agreed. Under an intent (L8): a + crash between the issue and the item is settled at boot.""" + project = self._projects.get(loop.proposal_project) + summary = self._latest_proposal(item) + if project is None or not summary: + note = "that proposal can't be filed any more; what next?" + self._wait(item, replace(loop, wait=ANSWER, note=note), note) + return + new_id = f"{project.name}-c{uuid.uuid4().hex[:6]}" + body = ( + f"{summary}\n\nFrom a conversation with the operator, agreed there as " + f"rev-{loop.rev}.\n\n" + ) + intent = self._store.begin_intent( + FILE_ISSUE, + {"repo": project.repo, "item": new_id, "title": loop.proposal_title}, + cause=f"filing {loop.proposal_title}", + work_item=item.id, + ) + try: + number = self._gitea.create_issue(project.repo, loop.proposal_title, body) + self._gitea.assign_issue(project.repo, number, self._account) + except GiteaError as exc: + self._store.abort_intent(intent, cause=f"Gitea refused: {exc}") + note = f"I couldn't open the issue: {exc}. Say go to try again." + self._wait(item, replace(loop, wait=GO_AHEAD, note=note), note) + return + self._start_filed(item, project, new_id, number, loop.proposal_title, summary) + self._store.commit_intent(intent, cause=f"opened {project.repo}#{number}") + + def _start_filed( + self, + conversation: WorkItem, + project: Project, + new_id: str, + number: int, + title: str, + summary: str, + ) -> None: + """The filed issue's item, agreed already, and the conversation told + where the work went.""" + root = self._transport.post( + f"Queued {project.repo}#{number}: {title} (from a conversation)" + ) + new = Loop( + thread=root, + issue=number, + labels=project.labels, + started=True, # agreed in the conversation + follows=conversation.id, + ) + self._threads[(project.name, number)] = root + self._store.open_work_item( + new_id, + title=title, + scope=project.name, + position=READY, + data={"loop": new.to_data(), "roles": self._roles}, + cause=f"filed {project.repo}#{number} from {conversation.id}", + ) + self._store.transcript(new_id, "orchestrator").append( + Fact( + text=f"work item opened from {project.repo}#{number} ({title}), " + f"filed from the conversation {conversation.id}, where the operator " + f"agreed to this brief:\n{summary}" + ) + ) + loop = loop_of(self._store.work_item(conversation.id) or conversation) + loop = replace( + loop, + became=(*loop.became, f"{project.repo}#{number}"), + wait=CHAT, + note="", + proposal_project="", + proposal_title="", + idle_since=self._clock(), + ) + self._fact(conversation, f"filed {project.repo}#{number} as {new_id}") + self._wait( + conversation, + loop, + f"Opened {project.repo}#{number} and started on it in its own thread. " + "We can keep talking here.", + ) + + def _latest_proposal(self, item: WorkItem) -> str: + proposals = [ + e.event.plan + for e in self._store.transcript(item.id, "driver").read() + if isinstance(e.event, PlanSubmitted) + ] + return proposals[-1] if proposals else "" + def _plain_go(self, item: WorkItem, text: str) -> bool: """Whether a reply that isn't a command agrees to the restatement up. Questions never do (L5).""" @@ -718,7 +971,7 @@ class LoopAgent: def _which(self, command: str) -> str: """L5: an unnamed command at top level is ambiguous: ask.""" - items = self._store.work_items("open") + items = [i for i in self._store.work_items("open") if i.scope != GENERAL] if not items: return f"{command} what? I have no open work items." listed = ", ".join(f"{self._label(i)} ({i.title})" for i in items) @@ -779,7 +1032,9 @@ class LoopAgent: for item in items: loop = loop_of(item) what = item.position - if item.position == WAITING: + if item.position == WAITING and loop.wait == CHAT: + what = "talking; your turn" + elif item.position == WAITING: what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})" if loop.grace_until: left = max(0, int(loop.grace_until - self._clock()) // 60) @@ -788,7 +1043,12 @@ class LoopAgent: 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"{self._label(item)} {item.title}: {what}") + name = ( + f'conversation "{item.title}"' + if item.scope == GENERAL + else f"{self._label(item)} {item.title}" + ) + lines.append(f"{name}: {what}") return "\n".join(lines) # -- running (L1, L2) ------------------------------------------------------ @@ -828,7 +1088,45 @@ class LoopAgent: ) self._last_project = here + def _converse_run(self, item: WorkItem) -> None: + """A conversation's turn (L1, L5): it talks, and may propose work on + a project. No workspace, nothing to run.""" + loop = loop_of(item) + self._store.set_active(item.id, cause="ready") + save(self._store, item.id, RUNNING, loop, cause="driver run") + projects = "\n".join(f"- {p.name}: {p.repo}" for p in self._projects.values()) + system = CONVERSATION.format(projects=f"The projects:\n{projects}") + self._active_thread = loop.thread + self._running = True + try: + outcome = self._driver.run( + self._store.transcript(item.id, "driver"), + system=system, + tools=[REPLY, ASK_HUMAN, PROPOSE_WORK], + workspace=None, + checks={"propose_work": self._proposal_problem}, + ) + finally: + self._running = False + self._cancel.clear() + self._settle(item.id, outcome) + + def _proposal_problem(self, args: dict[str, object]) -> str | None: + project = str(args.get("project", "")).strip() + if project not in self._projects: + names = ", ".join(self._projects) + return f"no project {project!r}: the projects are {names}" + if not str(args.get("title", "")).strip(): + return "the issue needs a title" + summary = str(args.get("summary", "")).strip() + if not summary: + return "the issue needs a summary: what was decided" + return _restatement_problem({"text": summary}) + def _run(self, item: WorkItem) -> None: + if item.scope == GENERAL: + self._converse_run(item) + return loop = loop_of(item) project = self._at(item) milestones = project.milestones @@ -926,6 +1224,43 @@ class LoopAgent: replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace), None, ) + elif outcome.kind == "wait" and outcome.tool == "reply": + text = str(args.get("text", "")).strip() + self._transport.post(text, thread=loop.thread) + self._wait( + item, + replace(loop, wait=CHAT, note="", idle_since=self._clock()), + None, + ) + elif outcome.kind == "wait" and outcome.tool == "propose_work": + project = self._projects[str(args.get("project", "")).strip()] + title = str(args.get("title", "")).strip() + summary = str(args.get("summary", "")).strip() + rev = loop.rev + 1 + self._store.transcript(item.id, "driver").append( + PlanSubmitted(plan=summary, model=self._roles.get("planner", "")) + ) + self._fact(item, f"work proposed as rev-{rev}: {project.repo}, {title}") + self._shown[loop.thread] = rev + self._transport.post( + f"rev-{rev}: I'd open an issue in {project.repo}, " + f'"{title}":\n\n{summary}\n\nSay go to open it and start, or ' + "reply with changes.", + thread=loop.thread, + ) + self._wait( + item, + replace( + loop, + rev=rev, + wait=GO_AHEAD, + note="", + proposal_project=project.name, + proposal_title=title, + idle_since=self._clock(), + ), + None, + ) elif outcome.kind == "wait" and outcome.tool == "ask_human": question = str(args.get("question", "")).strip() self._answer_where_asked(item, loop, question) diff --git a/nisse-dev/src/nisse_dev/loop/build.py b/nisse-dev/src/nisse_dev/loop/build.py index 09f0aec..6542204 100644 --- a/nisse-dev/src/nisse_dev/loop/build.py +++ b/nisse-dev/src/nisse_dev/loop/build.py @@ -134,6 +134,7 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop: # classifier role arrives (M2.6). reader=GoAheadReader(normalized, config.roles.resolve("planner")), bug_grace_s=config.tuning.loop.bug_grace_s, + general_idle_s=config.tuning.loop.general_idle_s, ) return RunningLoop( config=config, diff --git a/nisse-dev/src/nisse_dev/loop/items.py b/nisse-dev/src/nisse_dev/loop/items.py index 5cb1543..2d520ee 100644 --- a/nisse-dev/src/nisse_dev/loop/items.py +++ b/nisse-dev/src/nisse_dev/loop/items.py @@ -20,6 +20,7 @@ CLOSED = "closed" GO_AHEAD = "go-ahead" # a go on the restatement up (L3, D1) ANSWER = "answer" # an answer to a question MERGE = "merge" # the PR's review and merge (D4, D5) +CHAT = "conversation" # a conversation waits for the operator to say more (L5) @dataclass(frozen=True) @@ -43,6 +44,14 @@ class Loop: labels: tuple[str, ...] = () # the issue's labels at intake: they steer (D1) grace_until: float = 0.0 # a `bug` restatement proceeds then, unanswered talked: bool = False # the operator has spoken in the item's thread + # A conversation (`general`, L1): when it last heard or said anything, + # the conversation it continues, the work it turned into, and the work + # proposed for a go-ahead. + idle_since: float = 0.0 + follows: str = "" + became: tuple[str, ...] = () + proposal_project: str = "" + proposal_title: str = "" # 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. @@ -59,6 +68,7 @@ def loop_of(item: WorkItem) -> Loop: data["notes"] = tuple(data.get("notes", ())) data["blocked_by"] = tuple(data.get("blocked_by", ())) data["labels"] = tuple(data.get("labels", ())) + data["became"] = tuple(data.get("became", ())) return Loop(**data) @@ -75,11 +85,13 @@ def save( ) -def item_for_thread(store: StateStore, thread: str) -> WorkItem | None: - for item in store.work_items("open"): - if loop_of(item).thread == thread: - return item - return None +def item_for_thread( + store: StateStore, thread: str, status: str | None = "open" +) -> WorkItem | None: + """The item whose conversation is ``thread``; the latest, when a closed + conversation's thread was picked up again.""" + found = [i for i in store.work_items(status) if loop_of(i).thread == thread] + return found[-1] if found else None def item_for_issue( diff --git a/nisse-dev/src/nisse_dev/loop/tools.py b/nisse-dev/src/nisse_dev/loop/tools.py index 86815af..f946f04 100644 --- a/nisse-dev/src/nisse_dev/loop/tools.py +++ b/nisse-dev/src/nisse_dev/loop/tools.py @@ -66,8 +66,35 @@ WAIT_FOR_REVIEW = _tool( note="What you're waiting on, for the operator.", ) +# A conversation's tools (L1, L5): it talks, and may propose work. +REPLY = _tool( + "reply", + "Answer the operator in the conversation's thread. Ends your turn; you " + "continue when they say more.", + text="Your answer, in plain language.", +) +PROPOSE_WORK = _tool( + "propose_work", + "The conversation has turned into work on one of the projects: propose " + "an issue for it. The operator agrees to it as to a restatement; then the " + "issue is opened, and work on it starts as its own item. Ends your turn.", + project="The project's name, from the list in your instructions.", + title="A short title for the issue.", + summary="What was decided, distilled: the request, what it means, and the " + "first step. Not the conversation; it is the issue's body and the work's " + "whole brief.", +) + WAITING = frozenset( - {"restate", "ask_human", "close_item", "finish_milestone", "wait_for_review"} + { + "restate", + "ask_human", + "close_item", + "finish_milestone", + "wait_for_review", + "reply", + "propose_work", + } ) diff --git a/nisse-dev/tests/test_loop.py b/nisse-dev/tests/test_loop.py index 9c0e737..05d3a8e 100644 --- a/nisse-dev/tests/test_loop.py +++ b/nisse-dev/tests/test_loop.py @@ -50,6 +50,14 @@ class FakeGitea: self.closed: list[int] = [] self.deps: dict[int, list[int]] = {} + def create_issue(self, repo: str, title: str, body: str) -> int: + n = max([i.number for i in self.issues] + [0]) + 1 + self.issues.append(Issue(n, title, body, "nisse-dev", ())) + return n + + def assign_issue(self, repo: str, index: int, assignee: str) -> None: + pass + def open_dependencies(self, repo: str, index: int) -> list[str]: return [f"{repo}#{n}" for n in self.deps.get(index, []) if n not in self.closed] diff --git a/nisse-dev/tests/test_loop_conversations.py b/nisse-dev/tests/test_loop_conversations.py new file mode 100644 index 0000000..e327167 --- /dev/null +++ b/nisse-dev/tests/test_loop_conversations.py @@ -0,0 +1,239 @@ +"""Conversations on the new loop (M2.5, PR A): the `general` scope, a +thread per conversation, idle close and continuation (L1, L5), and agreed +work filed as a record issue whose item starts as agreed (D1, L8). Real git +on the throwaway build host; a fake Gitea.""" + +import re +from typing import Any + +from test_loop_milestones import DON, Forge, ScriptedModel, call, loop_agent +from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture + +from nisse_dev.canonical import Fact, ToolResult +from nisse_dev.gitea import Issue +from nisse_dev.loop.agent import FILE_ISSUE +from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, WAITING, loop_of + +PROPOSAL = call( + "propose_work", + project="sandbox", + title="Add count_lines", + summary="Add count_lines(text) to text.py, counting newline-separated lines, " + "with tests. First step: the function and its tests.", +) + + +def conversations(world: dict[str, Any]) -> list[Any]: + return [i for i in world["store"].work_items() if i.scope == "general"] + + +def facts(world: dict[str, Any], item: Any) -> list[str]: + return [ + e.event.text + for e in world["store"].transcript(item.id, "orchestrator").read() + if isinstance(e.event, Fact) + ] + + +def quiet(world: dict[str, Any]) -> None: + """No assigned issue: only conversations in these tests.""" + world["forge"].issues.clear() + + +def test_a_top_level_message_is_a_conversation_in_its_own_thread( + world: dict[str, Any], +) -> None: + quiet(world) + model = ScriptedModel( + [call("reply", text="A 4090 runs a 27B model at 4-bit comfortably.")], + [call("reply", text="Yes, with a smaller context.")], + ) + agent, transport = loop_agent(world, model) + asked = transport.receive(DON, "Could a 4090 run a 27B model?") + [chat] = conversations(world) + loop = loop_of(chat) + assert loop.thread == asked # the thread is under the operator's message + assert (chat.position, loop.wait) == (WAITING, CHAT) + reply = transport.outbound[-1] + assert reply.thread == asked and "4-bit" in reply.text + assert 'conversation "Could a 4090 run a 27B model?": talking' in agent.status() + + transport.receive(DON, "And a 32B?", thread=asked) + assert len(conversations(world)) == 1 # the same conversation + assert transport.outbound[-1].text == "Yes, with a smaller context." + + +def test_agreed_work_is_filed_and_starts_as_agreed(world: dict[str, Any]) -> None: + quiet(world) + forge: Forge = world["forge"] + model = ScriptedModel( + [PROPOSAL], + [ # the filed item's first run: straight to work, no restatement + call("start_milestone", name="count_lines"), + call("write_file", path="lines.py", content="L = 1\n"), + call("finish_milestone", summary="count_lines", final="yes"), + ], + ) + _, transport = loop_agent(world, model) + root = transport.receive(DON, "I'd like a line counter in the sandbox.") + assert transport.outbound[-1].text.startswith( + 'rev-1: I\'d open an issue in don/sandbox, "Add count_lines"' + ) + transport.receive(DON, "go", thread=root) + + [filed] = forge.issues + marker = re.search(r"", filed.body) + assert ( + marker and "count newline-separated lines" not in filed.body + ) # it's the summary + assert "counting newline-separated lines" in filed.body + assert forge.assigned == [filed.number] + work = world["store"].work_item(marker.group(1)) + assert work is not None and work.scope == "sandbox" + assert loop_of(work).issue == filed.number and loop_of(work).started + assert (work.position, loop_of(work).wait) == (WAITING, MERGE) # it worked + [pr] = forge.prs + assert f"Closes #{filed.number}" in forge.prs[pr]["body"] + # The conversation says where the work went and goes on. + [chat] = conversations(world) + assert loop_of(chat).became == (f"don/sandbox#{filed.number}",) + assert loop_of(chat).wait == CHAT + assert any("filed from the conversation" in f for f in facts(world, work)) + + +def test_intake_never_takes_a_record_issue(world: dict[str, Any]) -> None: + quiet(world) + forge: Forge = world["forge"] + body = "brief\n\n" + forge.issues.append(Issue(30, "a record", body, "nisse-dev", ())) + agent, _ = loop_agent(world, ScriptedModel()) + agent.intake() + assert world["store"].work_items() == [] + + +def test_a_proposal_for_an_unknown_project_is_sent_back(world: dict[str, Any]) -> None: + quiet(world) + model = ScriptedModel( + [call("propose_work", project="nope", title="t", summary="s.")], + [call("reply", text="Which project should it go in?")], + ) + _, transport = loop_agent(world, model) + transport.receive(DON, "Let's build a thing") + results = [ + e.event.result + for e in world["store"].transcript(conversations(world)[0].id, "driver").read() + if isinstance(e.event, ToolResult) + ] + assert any("no project 'nope': the projects are sandbox" in r for r in results) + assert loop_of(conversations(world)[0]).wait == CHAT + + +def test_changes_to_a_proposal_go_back_to_the_driver(world: dict[str, Any]) -> None: + quiet(world) + model = ScriptedModel([PROPOSAL], [PROPOSAL]) + _, transport = loop_agent(world, model) + root = transport.receive(DON, "a line counter please") + transport.receive(DON, "call it line_count instead", thread=root) + loop = loop_of(conversations(world)[0]) + assert (loop.wait, loop.rev) == (GO_AHEAD, 2) + assert world["forge"].issues == [] + + +def test_an_idle_conversation_closes_and_a_reply_continues_it( + world: dict[str, Any], +) -> None: + quiet(world) + model = ScriptedModel( + [call("reply", text="Sure.")], [call("reply", text="Picking up again.")] + ) + agent, transport = loop_agent(world, model) + clock = [1000.0] + agent._clock = lambda: clock[0] + agent._idle = 3600 + root = transport.receive(DON, "thinking about a backup tool") + clock[0] += 3599 + agent.intake() + assert conversations(world)[0].status == "open" + clock[0] += 1 + agent.intake() + [old] = conversations(world) + assert (old.status, old.outcome) == ("closed", "idle") + + transport.receive(DON, "back to that backup tool", thread=root) + new = [c for c in conversations(world) if c.status == "open"] + assert len(new) == 1 and loop_of(new[0]).follows == old.id + assert loop_of(new[0]).thread == root + assert any("this continues the conversation" in f for f in facts(world, new[0])) + + +def test_a_plain_yes_files_the_work(world: dict[str, Any]) -> None: + quiet(world) + + class Reader: + def agrees(self, proposal: str, reply: str) -> bool: + return reply == "sounds right" + + model = ScriptedModel([PROPOSAL], [call("ask_human", question="anything else?")]) + agent, transport = loop_agent(world, model) + agent._reader = Reader() + root = transport.receive(DON, "a line counter please") + transport.receive(DON, "sounds right", thread=root) + assert len(world["forge"].issues) == 1 + + +def test_a_filing_cut_off_by_a_crash_is_settled_at_boot(world: dict[str, Any]) -> None: + quiet(world) + forge: Forge = world["forge"] + model = ScriptedModel([PROPOSAL]) + _, transport = loop_agent(world, model) + root = transport.receive(DON, "a line counter please") + chat = conversations(world)[0] + store = world["store"] + # The issue was opened, then the agent died before its item existed. + intent = store.begin_intent( + FILE_ISSUE, + {"repo": "don/sandbox", "item": "sandbox-cdead01", "title": "Add count_lines"}, + cause="filing", + work_item=chat.id, + ) + forge.issues.append( + Issue( + 40, "Add count_lines", "b\n\n", "x", () + ) + ) + agent2, _ = loop_agent(world, ScriptedModel([call("ask_human", question="ok?")])) + agent2.boot() + work = store.work_item("sandbox-cdead01") + assert work is not None and loop_of(work).issue == 40 and loop_of(work).started + assert not [i for i in store.pending_intents() if i.id == intent] + assert root # the conversation itself is untouched + + +def test_a_filing_that_never_reached_gitea_is_put_up_again( + world: dict[str, Any], +) -> None: + quiet(world) + _, transport = loop_agent(world, ScriptedModel([PROPOSAL])) + transport.receive(DON, "a line counter please") + chat = conversations(world)[0] + world["store"].begin_intent( + FILE_ISSUE, + {"repo": "don/sandbox", "item": "sandbox-cnever1", "title": "Add count_lines"}, + cause="filing", + work_item=chat.id, + ) + agent2, _ = loop_agent(world, ScriptedModel()) + agent2.boot() + loop = loop_of(world["store"].work_item(chat.id)) + assert loop.wait == GO_AHEAD and "cut off by a restart" in loop.note + assert world["store"].work_item("sandbox-cnever1") is None + + +def test_commands_alone_at_top_level_are_not_conversations( + world: dict[str, Any], +) -> None: + quiet(world) + _, transport = loop_agent(world, ScriptedModel()) + transport.receive(DON, "status") + transport.receive(DON, "go") + assert conversations(world) == [] diff --git a/nisse-dev/tests/test_loop_milestones.py b/nisse-dev/tests/test_loop_milestones.py index 16d9888..ea03426 100644 --- a/nisse-dev/tests/test_loop_milestones.py +++ b/nisse-dev/tests/test_loop_milestones.py @@ -60,11 +60,20 @@ class Forge: self.closed_issues: list[int] = [] self.issues = [Issue(14, "Add count_words", "count words", "don", ())] self.deps: dict[int, list[int]] = {} # issue -> the issues it depends on + self.assigned: list[int] = [] # issues the agent assigned to itself # intake def assigned_issues(self, repo: str, assignee: str) -> list[Issue]: return [i for i in self.issues if i.number not in self.closed_issues] + def create_issue(self, repo: str, title: str, body: str) -> int: + n = max([i.number for i in self.issues] + [0]) + 1 + self.issues.append(Issue(n, title, body, "nisse-dev", ())) + return n + + def assign_issue(self, repo: str, index: int, assignee: str) -> None: + self.assigned.append(index) + def open_dependencies(self, repo: str, index: int) -> list[str]: return [ f"{repo}#{n}" diff --git a/nisse-dev/tests/test_loop_properties.py b/nisse-dev/tests/test_loop_properties.py index 2e072fa..5cfe7be 100644 --- a/nisse-dev/tests/test_loop_properties.py +++ b/nisse-dev/tests/test_loop_properties.py @@ -9,6 +9,7 @@ the seed and the steps, so it replays exactly. """ import random +import re import subprocess from pathlib import Path from typing import Any @@ -28,6 +29,7 @@ from nisse_dev.loop.driver import Driver from nisse_dev.loop.items import ( ANSWER, BLOCKED, + CHAT, GO_AHEAD, MERGE, RUNNING, @@ -49,8 +51,9 @@ class PolicyModel: after comments, close after a merge; sometimes asks a question or replies in prose. Seeded, so a walk replays exactly.""" - def __init__(self, rng: random.Random) -> None: + def __init__(self, rng: random.Random, projects: list[str]) -> None: self.rng = rng + self.projects = projects self.n = 0 def complete(self, events, tools, model, system=None) -> list[Any]: @@ -63,6 +66,17 @@ class PolicyModel: return [call("ask_human", question=f"question {self.n}?")] if roll < 0.12: return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})] + if "reply" in offered: # a conversation: talk, or propose work + if self.rng.random() < 0.4: + return [ + call( + "propose_work", + project=self.rng.choice(self.projects), + title=f"filed {self.n}", + summary=f"do thing {self.n}.", + ) + ] + return [call("reply", text=f"an answer {self.n}")] if "restate" not in offered and "finish_milestone" not in offered: return [call("ask_human", question=f"talk first {self.n}?")] # discuss if "finish_milestone" not in offered: @@ -101,7 +115,6 @@ class Walk: self.seed = seed self.steps: list[str] = [] self.shown_at_go: list[str] = [] # item:revision on screen at each go - self.next_issue = dict.fromkeys(worlds, 15) # numbers repeat across projects self.now = 1000.0 self.agent, self.transport = self._agent() @@ -132,7 +145,11 @@ class Walk: account="nisse-dev", operators={DON}, gitea_operators={"don"}, - driver=Driver(PolicyModel(self.rng), "m", cancel=cancel), # type: ignore[arg-type] + driver=Driver( + PolicyModel(self.rng, list(self.worlds)), # type: ignore[arg-type] + "m", + cancel=cancel, + ), worker=InlineWorker(), cancel=cancel, oplog=OpLog("nisse-dev", _Null()), @@ -158,15 +175,26 @@ class Walk: open_items = [i for i in self.items() if i.status == "open"] # Weighted towards what an item can actually do, so review paths # are walked often, not drowned out by intake and crashes. - weights = {"intake": 2 if not open_items else 1, "crash": 1, "issue": 1} + weights = { + "intake": 2 if not open_items else 1, + "crash": 1, + "issue": 1, + "chat": 1, + } item = self.rng.choice(open_items) if open_items else None loop = loop_of(item) if item else None # The forge an item's project lives on; a new issue picks one. - project = item.scope if item else self.rng.choice(list(self.worlds)) + project = ( + item.scope + if item and item.scope != "general" + else self.rng.choice(list(self.worlds)) + ) forge: Forge = self.worlds[project]["forge"] if item and loop: weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1}) - weights.update({"first": 1, "plain-yes": 2, "named-go": 1, "wait": 2}) + weights.update({"plain-yes": 2, "wait": 2}) + if item.scope != "general": # conversations aren't named by number + weights.update({"first": 1, "named-go": 1}) if loop.pr: weights.update( {"comment": 4, "merge": 2, "close-pr": 1, "comment-merge": 2} @@ -175,12 +203,15 @@ class Walk: self.steps.append(f"{action}{self.name(item)}" if item else action) if action == "intake": self.agent.intake() + elif action == "chat": + # A top-level message: a conversation of its own (L1, L5). + self.transport.receive(DON, f"thinking aloud {len(self.steps)}") elif action == "issue": # A new issue, sometimes depending on one that is still open. project = self.rng.choice(list(self.worlds)) forge = self.worlds[project]["forge"] - n = self.next_issue[project] - self.next_issue[project] += 1 + # Numbered by the forge, as filed records are: never reused. + n = max([i.number for i in forge.issues] + [14]) + 1 labels = self.rng.choice([(), (), ("bug",), ("discuss",), ("design",)]) forge.issues.append(Issue(n, f"task {n}", f"do task {n}", "don", labels)) live = [i.number for i in forge.assigned_issues("", "") if i.number != n] @@ -197,8 +228,8 @@ class Walk: self.shown_at_go.append(f"{item.id}:{loop.rev}") self.transport.receive(DON, f"go {self.name(item)}") elif action == "wait": - # Time passes: bug countdowns may run out. - self.now += self.rng.choice([60, 700]) + # Time passes: bug countdowns may run out, conversations idle. + self.now += self.rng.choice([60, 700, 90000]) for other in open_items: o = loop_of(other) if ( @@ -261,11 +292,10 @@ class Walk: assert len(issues) == len(set(issues)), f"one_item_per_issue: {where}" for item in items: loop = loop_of(item) - forge = self.worlds[item.scope]["forge"] - repo = self.worlds[item.scope]["repo_name"] if item.status == "open" and item.position == WAITING: explained = ( - (loop.wait == GO_AHEAD and loop.rev > 0) + (loop.wait == CHAT and item.scope == "general") + or (loop.wait == GO_AHEAD and loop.rev > 0) or (loop.wait == ANSWER and bool(loop.note)) or (loop.wait == MERGE and loop.pr > 0) ) @@ -281,6 +311,10 @@ class Walk: assert all(f"{item.id}:{rev}" in self.shown_at_go for rev in gos), ( f"go_binds: {item.id} {gos} not in {self.shown_at_go}: {where}" ) + if item.scope == "general": + continue # no forge: nothing below applies + forge = self.worlds[item.scope]["forge"] + repo = self.worlds[item.scope]["repo_name"] # Blocked comes from external truth (L1): an item whose issue # had an open dependency from the start never ran before it closed. if forge.open_dependencies(repo, loop.issue): @@ -337,12 +371,37 @@ class Walk: assert not [i for i in items if i.position == RUNNING], ( f"one_active (nothing left running): {where}" ) + for item in items: + if item.scope == "general": + loop = loop_of(item) + # L1: a conversation has no workspace, milestone or PR. + assert not (loop.prepared or loop.milestone or loop.pr), ( + f"conversations_do_no_work: {item.id}: {where}" + ) for name, w in self.worlds.items(): + self._records_have_one_item(name, w, where) self._own_repository_only(name, w, where) self._parked_work_safe(w, name, where) self._milestones_start_fresh(w, where) self._review_fixes_land(w, where) + def _records_have_one_item(self, name: str, world: Any, where: str) -> None: + """D1: a record issue filed from a conversation is its item's, from + the moment it exists, and intake never takes it for another.""" + store = self.world["store"] + for issue in world["forge"].issues: + found = re.search(r"", issue.body or "") + if not found: + continue + item = store.work_item(found.group(1)) + assert item is not None and item.scope == name, ( + f"records_have_one_item: {name}#{issue.number} has no item: {where}" + ) + assert loop_of(item).issue == issue.number, ( + f"records_have_one_item: {item.id} isn't #{issue.number}: {where}" + ) + assert loop_of(item).started, f"filed work starts agreed: {where}" + def _own_repository_only(self, name: str, world: Any, where: str) -> None: """D8: an item's branches and PRs reach only its own project's repository.""" diff --git a/tests/integration/test_end_to_end_loop.py b/tests/integration/test_end_to_end_loop.py index 0440942..87c36e0 100644 --- a/tests/integration/test_end_to_end_loop.py +++ b/tests/integration/test_end_to_end_loop.py @@ -482,3 +482,56 @@ def test_a_two_milestone_feature( operator.merge_and_close(issues[0]) finally: operator.tidy(issues, prs) + + +def test_a_conversation_becomes_filed_work( + running: tuple[RunningLoop, InMemoryTransport, str], +) -> None: + """M2.5: a top-level message is a conversation; the work it turns into + is proposed, agreed, filed as a record issue, and started as agreed.""" + agent, transport, run = running + operator = Operator(agent, transport) + gitea, repo = agent.gitea, agent.config.gitea_repo + issues: list[int] = [] + prs: list[int] = [] + + def conversation() -> Any: + items = agent.worker.call(agent.store.work_items) + return next(i for i in items if i.scope == "general") + + try: + root = transport.receive( + OPERATOR, + f"I've been thinking the sandbox could use this: {task(run)} " + "Could you file that as an issue and start on it?", + ) + operator.settle() + deadline = time.monotonic() + DEADLINE_S + answers = 0 + while loop_of(conversation()).wait != GO_AHEAD: + assert time.monotonic() < deadline, f"never proposed: {operator.last()}" + answers += 1 + assert answers <= MAX_ANSWERS, f"never proposed: {operator.last()}" + transport.receive( + OPERATOR, + "Yes, propose it as an issue in the sandbox project.", + thread=root, + ) + operator.settle() + assert operator.last().startswith(f"rev-{loop_of(conversation()).rev}:") + transport.receive(OPERATOR, "go", thread=root) + operator.settle() + + [filed] = loop_of(conversation()).became + number = int(filed.split("#")[1]) + issues.append(number) + data = gitea._request("GET", f"/repos/{repo}/issues/{number}") + assert "