Merge pull request 'Conversations on the new loop (M2.5, PR A)' (#106) from loop-conversations into main
CI / checks (push) Successful in 5m5s
CI / checks (push) Successful in 5m5s
This commit was merged in pull request #106.
This commit is contained in:
commit
6c962e1ed9
14 files changed
+811
-35
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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) == []
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user