Merge pull request 'Conversations on the new loop (M2.5, PR A)' (#106) from loop-conversations into main
CI / checks (push) Successful in 5m5s

This commit was merged in pull request #106.
This commit is contained in:
dev-agent committed 2026-10-06 04:33:05 +00:00
commit 6c962e1ed9
14 files changed
+811 -35

No files matched your search

+11
View File
@@ -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 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 repository the test operator creates under its own account for the run and
deletes afterwards. 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`, **M2.6 Roles and capability profiles.** *Pre-approved.* `driver`, `helper`,
`expert`, `classifier` (L9); escalation triggers; delegation and `ask_expert` on the `expert`, `classifier` (L9); escalation triggers; delegation and `ask_expert` on the
+10
View File
@@ -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 - **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 when you're around. A wrong start costs only time, since the fix still comes
as a PR you review. 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.
+1
View File
@@ -79,3 +79,4 @@ tuning:
safety_margin: 0.1 # fraction of the window held back safety_margin: 0.1 # fraction of the window held back
loop: # the new loop (loop: new) loop: # the new loop (loop: new)
bug_grace_s: 600 # a `bug` restatement proceeds after this long unanswered 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
+6
View File
@@ -68,6 +68,7 @@ PROJECT_OPTIONAL = (
# never in a command the model chose. # never in a command the model chose.
VISIBILITY = ("commands", "harness") VISIBILITY = ("commands", "harness")
_PROJECT_NAME = re.compile(r"[a-z0-9][a-z0-9-]*") _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_]*)\}") _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 # A restatement on a `bug` issue proceeds after this long without a
# reply (D1). # reply (D1).
bug_grace_s: float = 600.0 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) @dataclass(frozen=True)
@@ -247,6 +250,9 @@ def _parse_projects(
if not isinstance(name, str) or not _PROJECT_NAME.fullmatch(name): if not isinstance(name, str) or not _PROJECT_NAME.fullmatch(name):
errors.append(f"{where}: a project name is lowercase letters, digits, -") errors.append(f"{where}: a project name is lowercase letters, digits, -")
continue continue
if name == GENERAL:
errors.append(f"{where}: `{GENERAL}` is the scope of conversations (L1)")
continue
if not isinstance(entry, dict): if not isinstance(entry, dict):
errors.append(f"{where}: must be a mapping") errors.append(f"{where}: must be a mapping")
continue continue
+5
View File
@@ -261,6 +261,11 @@ class GiteaClient:
who = (event.get("user") or {}).get("login") who = (event.get("user") or {}).get("login")
return who 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: def close_issue(self, repo: str, index: int) -> None:
self._request( self._request(
"PATCH", f"/repos/{repo}/issues/{index}", json={"state": "closed"} "PATCH", f"/repos/{repo}/issues/{index}", json={"state": "closed"}
+351 -16
View File
@@ -24,6 +24,7 @@ from dataclasses import dataclass, replace
from nisse_dev.cancel import Cancel from nisse_dev.cancel import Cancel
from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage
from nisse_dev.config import GENERAL
from nisse_dev.gates import command_form from nisse_dev.gates import command_form
from nisse_dev.git import GitError from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaClient, GiteaError, Issue 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 ( from nisse_dev.loop.items import (
ANSWER, ANSWER,
BLOCKED, BLOCKED,
CHAT,
CLOSED, CLOSED,
GO_AHEAD, GO_AHEAD,
MERGE, MERGE,
@@ -49,6 +51,8 @@ from nisse_dev.loop.tools import (
ASK_HUMAN, ASK_HUMAN,
CLOSE_ITEM, CLOSE_ITEM,
FINISH_MILESTONE, FINISH_MILESTONE,
PROPOSE_WORK,
REPLY,
RESTATE, RESTATE,
START_MILESTONE, START_MILESTONE,
WAIT_FOR_REVIEW, 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). # 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"})
BUG_GRACE_S = 600.0 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"<!-- nisse-item: ([A-Za-z0-9-]+) -->")
# Before the go-ahead the driver may look around (read, list, run a command # 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. # 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}""" {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). # What an issue's labels add to the driver's instructions (D1).
LABELS = { LABELS = {
"bug": "This issue is labelled bug. Before restating, reproduce it with " "bug": "This issue is labelled bug. Before restating, reproduce it with "
@@ -177,6 +203,7 @@ class LoopAgent:
milestones: Milestones | None = None, milestones: Milestones | None = None,
reader: GoAheadReader | None = None, reader: GoAheadReader | None = None,
bug_grace_s: float = BUG_GRACE_S, bug_grace_s: float = BUG_GRACE_S,
general_idle_s: float = GENERAL_IDLE_S,
clock: Callable[[], float] = time.time, clock: Callable[[], float] = time.time,
intake_interval_s: float = INTAKE_INTERVAL_S, intake_interval_s: float = INTAKE_INTERVAL_S,
) -> None: ) -> None:
@@ -196,6 +223,7 @@ class LoopAgent:
self._roles = roles or {} self._roles = roles or {}
self._reader = reader self._reader = reader
self._grace = bug_grace_s self._grace = bug_grace_s
self._idle = general_idle_s
self._clock = clock self._clock = clock
self._interval = intake_interval_s self._interval = intake_interval_s
# thread root -> revision on screen there, read on the transport's # thread root -> revision on screen there, read on the transport's
@@ -214,11 +242,16 @@ class LoopAgent:
# -- projects (D8) ---------------------------------------------------------- # -- projects (D8) ----------------------------------------------------------
def _at(self, item: WorkItem) -> Project: 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 ""] return self._projects[item.scope or ""]
def _label(self, item: WorkItem) -> str: def _label(self, item: WorkItem) -> str:
"""How an item's issue is named to the operator: #14 with one """How an item's issue is named to the operator: #14 with one
project, sandbox#14 with more, where #14 alone could be either.""" 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 issue = loop_of(item).issue
return f"#{issue}" if len(self._projects) == 1 else f"{item.scope}#{issue}" return f"#{issue}" if len(self._projects) == 1 else f"{item.scope}#{issue}"
@@ -227,7 +260,9 @@ class LoopAgent:
return [ return [
i i
for i in self._store.work_items("open") 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: def _repos(self) -> str:
@@ -273,6 +308,7 @@ class LoopAgent:
work blindly: an item cut off mid-run waits on the operator.""" work blindly: an item cut off mid-run waits on the operator."""
self._store.set_active(None, cause="boot") self._store.set_active(None, cause="boot")
self._reconcile_publishing() self._reconcile_publishing()
self._reconcile_filing()
for item in self._store.work_items("open"): for item in self._store.work_items("open"):
loop = loop_of(item) loop = loop_of(item)
self._threads[(item.scope or "", loop.issue)] = loop.thread self._threads[(item.scope or "", loop.issue)] = loop.thread
@@ -287,12 +323,61 @@ class LoopAgent:
"to start, or reply with changes.", "to start, or reply with changes.",
thread=loop.thread, thread=loop.thread,
) )
else: elif loop.wait != CHAT: # a conversation just goes on
self._transport.post( self._transport.post(
f"(after a restart) still waiting: {loop.note}", f"(after a restart) still waiting: {loop.note}",
thread=loop.thread, 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: def _reconcile_publishing(self) -> None:
"""L8 step 2: a publish cut off by a crash is settled against Gitea. """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 An open PR for the branch is adopted; otherwise the intent is aborted
@@ -337,6 +422,7 @@ class LoopAgent:
self.watch() self.watch()
self.check_blocked() self.check_blocked()
self.check_grace() self.check_grace()
self.check_idle()
self._schedule() self._schedule()
def _intake(self, project: Project) -> None: def _intake(self, project: Project) -> None:
@@ -349,6 +435,8 @@ class LoopAgent:
for issue in issues: for issue in issues:
if item_for_issue(self._store, issue.number, project.name) is not None: if item_for_issue(self._store, issue.number, project.name) is not None:
continue continue
if _MARKER.search(issue.body or ""):
continue # D1: a record of work started in conversation
if not self._from_an_operator(project, issue): if not self._from_an_operator(project, issue):
self._oplog.emit( self._oplog.emit(
"loop.intake_ignored", "loop.intake_ignored",
@@ -441,7 +529,7 @@ class LoopAgent:
waiting on the operator, and an agent that finds a blocker mid-work waiting on the operator, and an agent that finds a blocker mid-work
asks before recording it.""" asks before recording it."""
for item in self._store.work_items("open"): 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 continue
loop = loop_of(item) loop = loop_of(item)
repo = self._at(item).repo repo = self._at(item).repo
@@ -469,6 +557,32 @@ class LoopAgent:
cause=said, 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: def check_grace(self) -> None:
"""D1: a `bug` restatement left unanswered for the grace period is """D1: a `bug` restatement left unanswered for the grace period is
taken as agreed, bound to the revision that was up all along.""" taken as agreed, bound to the revision that was up all along."""
@@ -613,6 +727,11 @@ class LoopAgent:
) )
return return
[item] = found [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: if item is None:
self._store.transcript(None, "operator").append( self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}") UserMessage(text=text, source=f"transport:{msg.sender}")
@@ -621,16 +740,8 @@ class LoopAgent:
self._transport.post(self.status(), thread=msg.thread) self._transport.post(self.status(), thread=msg.thread)
elif command in _NEEDS_ITEM: elif command in _NEEDS_ITEM:
self._transport.post(self._which(command), thread=msg.thread) self._transport.post(self._which(command), thread=msg.thread)
elif command == "stop": elif not self._store.active_item():
if not self._store.active_item(): self._transport.post("Nothing is running.", thread=msg.thread)
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,
)
return return
self._store.transcript(item.id, "operator").append( self._store.transcript(item.id, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}") UserMessage(text=text, source=f"transport:{msg.sender}")
@@ -643,6 +754,7 @@ class LoopAgent:
asked_text="", asked_text="",
grace_until=0.0, grace_until=0.0,
talked=True, talked=True,
idle_since=self._clock(),
) )
if loop != loop_of(item): if loop != loop_of(item):
save(self._store, item.id, item.position, loop, cause="operator spoke") save(self._store, item.id, item.position, loop, cause="operator spoke")
@@ -694,8 +806,53 @@ class LoopAgent:
) )
self._schedule() 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: def _go(self, item: WorkItem, loop: Loop, by: str) -> None:
self._fact(item, f"go-ahead on rev-{loop.rev} from {by}") self._fact(item, f"go-ahead on rev-{loop.rev} from {by}")
if item.scope == GENERAL:
self._file_work(item, loop)
return
save( save(
self._store, self._store,
item.id, item.id,
@@ -704,6 +861,102 @@ class LoopAgent:
cause=f"go-ahead on rev-{loop.rev}", 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<!-- nisse-item: {new_id} -->"
)
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: def _plain_go(self, item: WorkItem, text: str) -> bool:
"""Whether a reply that isn't a command agrees to the restatement up. """Whether a reply that isn't a command agrees to the restatement up.
Questions never do (L5).""" Questions never do (L5)."""
@@ -718,7 +971,7 @@ class LoopAgent:
def _which(self, command: str) -> str: def _which(self, command: str) -> str:
"""L5: an unnamed command at top level is ambiguous: ask.""" """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: if not items:
return f"{command} what? I have no open work items." return f"{command} what? I have no open work items."
listed = ", ".join(f"{self._label(i)} ({i.title})" for i in items) listed = ", ".join(f"{self._label(i)} ({i.title})" for i in items)
@@ -779,7 +1032,9 @@ class LoopAgent:
for item in items: for item in items:
loop = loop_of(item) loop = loop_of(item)
what = item.position 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}'})" what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
if loop.grace_until: if loop.grace_until:
left = max(0, int(loop.grace_until - self._clock()) // 60) left = max(0, int(loop.grace_until - self._clock()) // 60)
@@ -788,7 +1043,12 @@ class LoopAgent:
what = f"blocked by {', '.join(loop.blocked_by)}" what = f"blocked by {', '.join(loop.blocked_by)}"
elif item.position == READY: elif item.position == READY:
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line" 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) return "\n".join(lines)
# -- running (L1, L2) ------------------------------------------------------ # -- running (L1, L2) ------------------------------------------------------
@@ -828,7 +1088,45 @@ class LoopAgent:
) )
self._last_project = here 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: def _run(self, item: WorkItem) -> None:
if item.scope == GENERAL:
self._converse_run(item)
return
loop = loop_of(item) loop = loop_of(item)
project = self._at(item) project = self._at(item)
milestones = project.milestones milestones = project.milestones
@@ -926,6 +1224,43 @@ class LoopAgent:
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace), replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
None, 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": elif outcome.kind == "wait" and outcome.tool == "ask_human":
question = str(args.get("question", "")).strip() question = str(args.get("question", "")).strip()
self._answer_where_asked(item, loop, question) self._answer_where_asked(item, loop, question)
+1
View File
@@ -134,6 +134,7 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
# classifier role arrives (M2.6). # classifier role arrives (M2.6).
reader=GoAheadReader(normalized, config.roles.resolve("planner")), reader=GoAheadReader(normalized, config.roles.resolve("planner")),
bug_grace_s=config.tuning.loop.bug_grace_s, bug_grace_s=config.tuning.loop.bug_grace_s,
general_idle_s=config.tuning.loop.general_idle_s,
) )
return RunningLoop( return RunningLoop(
config=config, config=config,
+17 -5
View File
@@ -20,6 +20,7 @@ CLOSED = "closed"
GO_AHEAD = "go-ahead" # a go on the restatement up (L3, D1) GO_AHEAD = "go-ahead" # a go on the restatement up (L3, D1)
ANSWER = "answer" # an answer to a question ANSWER = "answer" # an answer to a question
MERGE = "merge" # the PR's review and merge (D4, D5) 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) @dataclass(frozen=True)
@@ -43,6 +44,14 @@ class Loop:
labels: tuple[str, ...] = () # the issue's labels at intake: they steer (D1) labels: tuple[str, ...] = () # the issue's labels at intake: they steer (D1)
grace_until: float = 0.0 # a `bug` restatement proceeds then, unanswered grace_until: float = 0.0 # a `bug` restatement proceeds then, unanswered
talked: bool = False # the operator has spoken in the item's thread 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 # 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 # Matrix thread, or the PR it came from, with the inline location and a
# little of what they said, for quoting. # little of what they said, for quoting.
@@ -59,6 +68,7 @@ def loop_of(item: WorkItem) -> Loop:
data["notes"] = tuple(data.get("notes", ())) data["notes"] = tuple(data.get("notes", ()))
data["blocked_by"] = tuple(data.get("blocked_by", ())) data["blocked_by"] = tuple(data.get("blocked_by", ()))
data["labels"] = tuple(data.get("labels", ())) data["labels"] = tuple(data.get("labels", ()))
data["became"] = tuple(data.get("became", ()))
return Loop(**data) return Loop(**data)
@@ -75,11 +85,13 @@ def save(
) )
def item_for_thread(store: StateStore, thread: str) -> WorkItem | None: def item_for_thread(
for item in store.work_items("open"): store: StateStore, thread: str, status: str | None = "open"
if loop_of(item).thread == thread: ) -> WorkItem | None:
return item """The item whose conversation is ``thread``; the latest, when a closed
return None 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( def item_for_issue(
+28 -1
View File
@@ -66,8 +66,35 @@ WAIT_FOR_REVIEW = _tool(
note="What you're waiting on, for the operator.", 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( 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",
}
) )
+8
View File
@@ -50,6 +50,14 @@ class FakeGitea:
self.closed: list[int] = [] self.closed: list[int] = []
self.deps: dict[int, 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]: 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] return [f"{repo}#{n}" for n in self.deps.get(index, []) if n not in self.closed]
+239
View File
@@ -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"<!-- nisse-item: ([\w-]+) -->", 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<!-- nisse-item: sandbox-cabc123 -->"
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<!-- nisse-item: sandbox-cdead01 -->", "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) == []
+9
View File
@@ -60,11 +60,20 @@ class Forge:
self.closed_issues: list[int] = [] self.closed_issues: list[int] = []
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
# intake # intake
def assigned_issues(self, repo: str, assignee: str) -> list[Issue]: 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] 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]: def open_dependencies(self, repo: str, index: int) -> list[str]:
return [ return [
f"{repo}#{n}" f"{repo}#{n}"
+72 -13
View File
@@ -9,6 +9,7 @@ the seed and the steps, so it replays exactly.
""" """
import random import random
import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -28,6 +29,7 @@ from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import ( from nisse_dev.loop.items import (
ANSWER, ANSWER,
BLOCKED, BLOCKED,
CHAT,
GO_AHEAD, GO_AHEAD,
MERGE, MERGE,
RUNNING, RUNNING,
@@ -49,8 +51,9 @@ class PolicyModel:
after comments, close after a merge; sometimes asks a question or after comments, close after a merge; sometimes asks a question or
replies in prose. Seeded, so a walk replays exactly.""" 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.rng = rng
self.projects = projects
self.n = 0 self.n = 0
def complete(self, events, tools, model, system=None) -> list[Any]: 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}?")] return [call("ask_human", question=f"question {self.n}?")]
if roll < 0.12: if roll < 0.12:
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})] 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: if "restate" not in offered and "finish_milestone" not in offered:
return [call("ask_human", question=f"talk first {self.n}?")] # discuss return [call("ask_human", question=f"talk first {self.n}?")] # discuss
if "finish_milestone" not in offered: if "finish_milestone" not in offered:
@@ -101,7 +115,6 @@ class Walk:
self.seed = seed self.seed = seed
self.steps: list[str] = [] self.steps: list[str] = []
self.shown_at_go: list[str] = [] # item:revision on screen at each go 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.now = 1000.0
self.agent, self.transport = self._agent() self.agent, self.transport = self._agent()
@@ -132,7 +145,11 @@ class Walk:
account="nisse-dev", account="nisse-dev",
operators={DON}, operators={DON},
gitea_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(), worker=InlineWorker(),
cancel=cancel, cancel=cancel,
oplog=OpLog("nisse-dev", _Null()), oplog=OpLog("nisse-dev", _Null()),
@@ -158,15 +175,26 @@ class Walk:
open_items = [i for i in self.items() if i.status == "open"] open_items = [i for i in self.items() if i.status == "open"]
# Weighted towards what an item can actually do, so review paths # Weighted towards what an item can actually do, so review paths
# are walked often, not drowned out by intake and crashes. # 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 item = self.rng.choice(open_items) if open_items else None
loop = loop_of(item) if item else None loop = loop_of(item) if item else None
# The forge an item's project lives on; a new issue picks one. # 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"] forge: Forge = self.worlds[project]["forge"]
if item and loop: if item and loop:
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1}) 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: if loop.pr:
weights.update( weights.update(
{"comment": 4, "merge": 2, "close-pr": 1, "comment-merge": 2} {"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) self.steps.append(f"{action}{self.name(item)}" if item else action)
if action == "intake": if action == "intake":
self.agent.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": elif action == "issue":
# A new issue, sometimes depending on one that is still open. # A new issue, sometimes depending on one that is still open.
project = self.rng.choice(list(self.worlds)) project = self.rng.choice(list(self.worlds))
forge = self.worlds[project]["forge"] forge = self.worlds[project]["forge"]
n = self.next_issue[project] # Numbered by the forge, as filed records are: never reused.
self.next_issue[project] += 1 n = max([i.number for i in forge.issues] + [14]) + 1
labels = self.rng.choice([(), (), ("bug",), ("discuss",), ("design",)]) labels = self.rng.choice([(), (), ("bug",), ("discuss",), ("design",)])
forge.issues.append(Issue(n, f"task {n}", f"do task {n}", "don", labels)) 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] 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.shown_at_go.append(f"{item.id}:{loop.rev}")
self.transport.receive(DON, f"go {self.name(item)}") self.transport.receive(DON, f"go {self.name(item)}")
elif action == "wait": elif action == "wait":
# Time passes: bug countdowns may run out. # Time passes: bug countdowns may run out, conversations idle.
self.now += self.rng.choice([60, 700]) self.now += self.rng.choice([60, 700, 90000])
for other in open_items: for other in open_items:
o = loop_of(other) o = loop_of(other)
if ( if (
@@ -261,11 +292,10 @@ class Walk:
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)
forge = self.worlds[item.scope]["forge"]
repo = self.worlds[item.scope]["repo_name"]
if item.status == "open" and item.position == WAITING: if item.status == "open" and item.position == WAITING:
explained = ( 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 == ANSWER and bool(loop.note))
or (loop.wait == MERGE and loop.pr > 0) 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), ( 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}" 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 # Blocked comes from external truth (L1): an item whose issue
# had an open dependency from the start never ran before it closed. # had an open dependency from the start never ran before it closed.
if forge.open_dependencies(repo, loop.issue): if forge.open_dependencies(repo, loop.issue):
@@ -337,12 +371,37 @@ class Walk:
assert not [i for i in items if i.position == RUNNING], ( assert not [i for i in items if i.position == RUNNING], (
f"one_active (nothing left running): {where}" 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(): for name, w in self.worlds.items():
self._records_have_one_item(name, w, where)
self._own_repository_only(name, w, where) self._own_repository_only(name, w, where)
self._parked_work_safe(w, name, where) self._parked_work_safe(w, name, where)
self._milestones_start_fresh(w, where) self._milestones_start_fresh(w, where)
self._review_fixes_land(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"<!-- nisse-item: ([\w-]+) -->", 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: 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 """D8: an item's branches and PRs reach only its own project's
repository.""" repository."""
+53
View File
@@ -482,3 +482,56 @@ def test_a_two_milestone_feature(
operator.merge_and_close(issues[0]) operator.merge_and_close(issues[0])
finally: finally:
operator.tidy(issues, prs) 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 "<!-- nisse-item: " in data["body"]
assert agent.account in [a["login"] for a in data["assignees"] or []]
item = operator.drive_until(MERGE, number)
assert loop_of(item).started # no second restatement
prs.append(loop_of(item).pr)
pr = gitea._request("GET", f"/repos/{repo}/pulls/{prs[0]}")
assert f"Closes #{number}" in pr["body"]
finally:
operator.tidy(issues, prs)