Merge pull request 'Fixes from the first live session of the new loop' (#119) from live-fixes into main
This commit was merged in pull request #119.
This commit is contained in:
commit
613a2a7f72
9 files changed
+341
-42
No files matched your search
@@ -33,6 +33,7 @@ from nisse_dev.canonical import (
|
|||||||
AssistantText,
|
AssistantText,
|
||||||
DelegationCompleted,
|
DelegationCompleted,
|
||||||
DelegationStarted,
|
DelegationStarted,
|
||||||
|
Fact,
|
||||||
PlanSubmitted,
|
PlanSubmitted,
|
||||||
StoredEvent,
|
StoredEvent,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -414,6 +415,15 @@ class Delegation:
|
|||||||
# and its tree state is unknown. Treat it as a failure rather
|
# and its tree state is unknown. Treat it as a failure rather
|
||||||
# than inventing a handback on its behalf.
|
# than inventing a handback on its behalf.
|
||||||
if any(isinstance(e, AssistantText) for e in produced):
|
if any(isinstance(e, AssistantText) for e in produced):
|
||||||
|
# Said something but did nothing: told so, as the
|
||||||
|
# harness, so the next request ends on the user's side
|
||||||
|
# (models refuse assistant prefill).
|
||||||
|
transcript.as_actor("orchestrator").append(
|
||||||
|
Fact(
|
||||||
|
text="Your last reply was prose. Use your tools to "
|
||||||
|
"do the work, then call hand_back."
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
return DelegationResult(
|
return DelegationResult(
|
||||||
"failed",
|
"failed",
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ asking is better than guessing.
|
|||||||
Before each group of tool calls, say in a sentence what you're doing and why:
|
Before each group of tool calls, say in a sentence what you're doing and why:
|
||||||
the operator sees it as your progress.
|
the operator sees it as your progress.
|
||||||
|
|
||||||
|
When the operator says something that needs an answer but no change (a
|
||||||
|
remark, a status question), answer with reply: the item goes back to what it
|
||||||
|
was waiting for. Every reply of yours is a tool call, never bare prose.
|
||||||
|
|
||||||
The project's documents are in its repository: read ARCHITECTURE.md and any
|
The project's documents are in its repository: read ARCHITECTURE.md and any
|
||||||
build plans under docs/plans/ before you restate.
|
build plans under docs/plans/ before you restate.
|
||||||
|
|
||||||
@@ -587,6 +591,8 @@ class LoopAgent:
|
|||||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||||
elif new and item.position == WAITING:
|
elif new and item.position == WAITING:
|
||||||
position = READY
|
position = READY
|
||||||
|
# A comment can be answered with a reply, back to this wait.
|
||||||
|
loop = replace(loop, back_to=loop.wait, back_note=loop.note)
|
||||||
if position != item.position or loop != loop_of(item):
|
if position != item.position or loop != loop_of(item):
|
||||||
if position == READY:
|
if position == READY:
|
||||||
loop = replace(loop, wait="", note="")
|
loop = replace(loop, wait="", note="")
|
||||||
@@ -824,6 +830,19 @@ class LoopAgent:
|
|||||||
elif not self._store.active_item():
|
elif not self._store.active_item():
|
||||||
self._transport.post("Nothing is running.", thread=msg.thread)
|
self._transport.post("Nothing is running.", thread=msg.thread)
|
||||||
return
|
return
|
||||||
|
if command in ("status", "details"):
|
||||||
|
# About the item, not a reply to it: nothing changes, a
|
||||||
|
# countdown runs on (L5). Recorded at the agent's level.
|
||||||
|
self._store.transcript(None, "operator").append(
|
||||||
|
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||||
|
)
|
||||||
|
said = (
|
||||||
|
self._details(item)
|
||||||
|
if command == "details"
|
||||||
|
else self._status_line(item, [i.id for i in self._queue()])
|
||||||
|
)
|
||||||
|
self._transport.post(said, thread=loop_of(item).thread)
|
||||||
|
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}")
|
||||||
)
|
)
|
||||||
@@ -844,8 +863,6 @@ class LoopAgent:
|
|||||||
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
|
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
|
||||||
elif command == "done" and loop.new_project:
|
elif command == "done" and loop.new_project:
|
||||||
self._provisioned(item, loop)
|
self._provisioned(item, loop)
|
||||||
elif command == "details":
|
|
||||||
self._transport.post(self._details(item), thread=loop.thread)
|
|
||||||
elif (
|
elif (
|
||||||
item.position == WAITING
|
item.position == WAITING
|
||||||
and item.scope != GENERAL
|
and item.scope != GENERAL
|
||||||
@@ -903,12 +920,13 @@ class LoopAgent:
|
|||||||
)
|
)
|
||||||
self._go(item, loop, f"{msg.sender} (in plain language)")
|
self._go(item, loop, f"{msg.sender} (in plain language)")
|
||||||
elif item.position == WAITING:
|
elif item.position == WAITING:
|
||||||
# An answer, or changes to a restatement: the driver reads it.
|
# An answer, changes to a restatement, or a remark: the driver
|
||||||
|
# reads it, and may reply and go back to this wait (L5).
|
||||||
save(
|
save(
|
||||||
self._store,
|
self._store,
|
||||||
item.id,
|
item.id,
|
||||||
READY,
|
READY,
|
||||||
replace(loop, wait="", note=""),
|
replace(loop, wait="", note="", back_to=loop.wait, back_note=loop.note),
|
||||||
cause="the operator answered",
|
cause="the operator answered",
|
||||||
)
|
)
|
||||||
self._schedule()
|
self._schedule()
|
||||||
@@ -1272,28 +1290,28 @@ class LoopAgent:
|
|||||||
f"{self._repos()}."
|
f"{self._repos()}."
|
||||||
)
|
)
|
||||||
queue = [i.id for i in self._queue()]
|
queue = [i.id for i in self._queue()]
|
||||||
lines = []
|
return "\n".join(self._status_line(item, queue) for item in items)
|
||||||
for item in items:
|
|
||||||
loop = loop_of(item)
|
def _status_line(self, item: WorkItem, queue: list[str]) -> str:
|
||||||
what = item.position
|
loop = loop_of(item)
|
||||||
if item.position == WAITING and loop.wait == CHAT:
|
what = item.position
|
||||||
what = "talking; your turn"
|
if item.position == WAITING and loop.wait == CHAT:
|
||||||
elif item.position == WAITING:
|
what = "talking; your turn"
|
||||||
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
|
elif item.position == WAITING:
|
||||||
if loop.grace_until:
|
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
|
||||||
left = max(0, int(loop.grace_until - self._clock()) // 60)
|
if loop.grace_until:
|
||||||
what += f", going ahead in {left} min unless you reply"
|
left = max(0, int(loop.grace_until - self._clock()) // 60)
|
||||||
elif item.position == BLOCKED:
|
what += f", going ahead in {left} min unless you reply"
|
||||||
what = f"blocked by {', '.join(loop.blocked_by)}"
|
elif item.position == BLOCKED:
|
||||||
elif item.position == READY:
|
what = f"blocked by {', '.join(loop.blocked_by)}"
|
||||||
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
elif item.position == READY:
|
||||||
name = (
|
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
||||||
f'conversation "{item.title}"'
|
name = (
|
||||||
if item.scope == GENERAL
|
f'conversation "{item.title}"'
|
||||||
else f"{self._label(item)} {item.title}"
|
if item.scope == GENERAL
|
||||||
)
|
else f"{self._label(item)} {item.title}"
|
||||||
lines.append(f"{name}: {what}")
|
)
|
||||||
return "\n".join(lines)
|
return f"{name}: {what}"
|
||||||
|
|
||||||
# -- running (L1, L2) ------------------------------------------------------
|
# -- running (L1, L2) ------------------------------------------------------
|
||||||
|
|
||||||
@@ -1663,12 +1681,15 @@ class LoopAgent:
|
|||||||
CLOSE_ITEM,
|
CLOSE_ITEM,
|
||||||
*([WAIT_FOR_REVIEW] if loop.pr else []),
|
*([WAIT_FOR_REVIEW] if loop.pr else []),
|
||||||
*([DELEGATE, ASK_EXPERT] if self._helpers else []),
|
*([DELEGATE, ASK_EXPERT] if self._helpers else []),
|
||||||
|
*([REPLY] if loop.back_to else []),
|
||||||
*EXECUTOR_TOOLS,
|
*EXECUTOR_TOOLS,
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
if loop.question:
|
if loop.question:
|
||||||
tools = [REPLY, *_LOOK]
|
tools = [REPLY, *_LOOK]
|
||||||
|
elif loop.back_to and not loop.started:
|
||||||
|
tools = [*tools, REPLY] # before the go-ahead, a remark answered
|
||||||
roles = self._roles_of(item)
|
roles = self._roles_of(item)
|
||||||
workspace = project.workspace
|
workspace = project.workspace
|
||||||
system = SYSTEM.format(
|
system = SYSTEM.format(
|
||||||
@@ -1771,6 +1792,13 @@ 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" and loop.back_to:
|
||||||
|
# A project item's reply: answered where asked (the thread or the
|
||||||
|
# PR), then back to the very wait the message interrupted (L5).
|
||||||
|
self._answer_where_asked(item, loop, str(args.get("text", "")).strip())
|
||||||
|
self._wait(
|
||||||
|
item, replace(loop, wait=loop.back_to, note=loop.back_note), None
|
||||||
|
)
|
||||||
elif outcome.kind == "wait" and outcome.tool == "reply":
|
elif outcome.kind == "wait" and outcome.tool == "reply":
|
||||||
text = str(args.get("text", "")).strip()
|
text = str(args.get("text", "")).strip()
|
||||||
self._transport.post(text, thread=loop.thread)
|
self._transport.post(text, thread=loop.thread)
|
||||||
@@ -2068,6 +2096,8 @@ class LoopAgent:
|
|||||||
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
||||||
if say:
|
if say:
|
||||||
self._transport.post(say, thread=loop.thread)
|
self._transport.post(say, thread=loop.thread)
|
||||||
|
# Waiting again: whatever woke the item is settled.
|
||||||
|
loop = replace(loop, question=False, back_to="", back_note="")
|
||||||
save(self._store, item.id, WAITING, loop, cause=f"waiting for {loop.wait}")
|
save(self._store, item.id, WAITING, loop, cause=f"waiting for {loop.wait}")
|
||||||
|
|
||||||
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
|
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from nisse_dev.cancel import Cancel, skip_calls
|
from nisse_dev.cancel import Cancel, skip_calls
|
||||||
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult
|
from nisse_dev.canonical import AssistantText, Fact, ToolCall, ToolResult
|
||||||
from nisse_dev.llm import LLMClient, LLMError
|
from nisse_dev.llm import LLMClient, LLMError
|
||||||
from nisse_dev.loop.tools import WAITING, names
|
from nisse_dev.loop.tools import WAITING, names
|
||||||
from nisse_dev.state import Transcript
|
from nisse_dev.state import Transcript
|
||||||
@@ -100,7 +100,10 @@ class Driver:
|
|||||||
if nudged:
|
if nudged:
|
||||||
return RunOutcome("failed", note="kept replying in prose")
|
return RunOutcome("failed", note="kept replying in prose")
|
||||||
nudged = True
|
nudged = True
|
||||||
system = f"{system}\n\n{_PROSE_NUDGE}"
|
# In the conversation, as the harness: a request must end on
|
||||||
|
# the user's side (models refuse assistant prefill), and the
|
||||||
|
# nudge is then in the log as what it was.
|
||||||
|
transcript.as_actor("orchestrator").append(Fact(text=_PROSE_NUDGE))
|
||||||
continue
|
continue
|
||||||
for index, call in enumerate(calls):
|
for index, call in enumerate(calls):
|
||||||
if self._cancel.requested:
|
if self._cancel.requested:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from dataclasses import replace
|
|||||||
from nisse_dev.canonical import (
|
from nisse_dev.canonical import (
|
||||||
AssistantText,
|
AssistantText,
|
||||||
DelegationCompleted,
|
DelegationCompleted,
|
||||||
|
DelegationStarted,
|
||||||
Fact,
|
Fact,
|
||||||
GateOpened,
|
GateOpened,
|
||||||
PlanSubmitted,
|
PlanSubmitted,
|
||||||
@@ -105,6 +106,13 @@ def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
|||||||
if viewer == "helper":
|
if viewer == "helper":
|
||||||
out.append(_said(stored, "approved plan", event.plan))
|
out.append(_said(stored, "approved plan", event.plan))
|
||||||
continue
|
continue
|
||||||
|
if isinstance(event, DelegationStarted):
|
||||||
|
# The harness handing a task over: to the helper given it, a
|
||||||
|
# message from the harness, never its own turn (a request must
|
||||||
|
# end on the user's side; models refuse assistant prefill).
|
||||||
|
if actor == viewer:
|
||||||
|
out.append(_said(stored, "orchestrator", f"your task: {event.brief}"))
|
||||||
|
continue
|
||||||
if actor == viewer:
|
if actor == viewer:
|
||||||
out.append(stored)
|
out.append(stored)
|
||||||
elif actor == "operator" and isinstance(event, UserMessage):
|
elif actor == "operator" and isinstance(event, UserMessage):
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ def assert_renderable(events: list[Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _anthropic(messages: list[dict[str, Any]]) -> None:
|
def _anthropic(messages: list[dict[str, Any]]) -> None:
|
||||||
|
# A request ends on the user's side: current models refuse assistant
|
||||||
|
# prefill ("The conversation must end with a user message"), found live
|
||||||
|
# on 2026-10-07 when the driver's prose nudge left its own reply last.
|
||||||
|
assert not messages or messages[-1]["role"] == "user", (
|
||||||
|
f"anthropic messages.{len(messages) - 1}: the conversation ends with an "
|
||||||
|
"assistant message (prefill), which the API refuses"
|
||||||
|
)
|
||||||
for index, message in enumerate(messages):
|
for index, message in enumerate(messages):
|
||||||
if message["role"] != "assistant":
|
if message["role"] != "assistant":
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""What the first live session of the new loop found (2026-10-07), each as a
|
||||||
|
test: a request ending on the assistant's side (refused by models without
|
||||||
|
prefill: the driver's prose nudge, a helper's first request), `status` in an
|
||||||
|
item's thread taken as a reply, and no way for the driver to answer a remark.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from model_contract import assert_renderable
|
||||||
|
from test_loop import RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
|
||||||
|
from test_loop_help import ROLES, hand_back
|
||||||
|
from test_loop_milestones import RESTATE, Forge, loop_agent, say
|
||||||
|
from test_loop_milestones import item as the_item
|
||||||
|
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||||
|
|
||||||
|
from nisse_dev.canonical import AssistantText, Fact
|
||||||
|
from nisse_dev.delegation import Caps
|
||||||
|
from nisse_dev.gitea import Comment
|
||||||
|
from nisse_dev.loop.help import Helpers
|
||||||
|
from nisse_dev.loop.items import GO_AHEAD, MERGE, WAITING, loop_of
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 1000.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prose_nudge_keeps_requests_ending_on_the_user_side(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
# ScriptedModel checks every request as the API would, now including
|
||||||
|
# "the conversation must end with a user message".
|
||||||
|
h = Harness(
|
||||||
|
tmp_path,
|
||||||
|
ScriptedModel([AssistantText(text="Let me think.", model="m")], RESTATED),
|
||||||
|
FakeGitea(issue()),
|
||||||
|
)
|
||||||
|
h.agent.intake()
|
||||||
|
assert loop_of(h.item()).wait == GO_AHEAD # the nudge worked
|
||||||
|
facts = [
|
||||||
|
e.event.text
|
||||||
|
for e in h.store.transcript(h.item().id, "orchestrator").read()
|
||||||
|
if isinstance(e.event, Fact)
|
||||||
|
]
|
||||||
|
assert any("Your last reply was prose" in f for f in facts)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractHelper:
|
||||||
|
"""A helper that checks its requests as the API would."""
|
||||||
|
|
||||||
|
def __init__(self, *turns: list[Any]) -> None:
|
||||||
|
self.turns = list(turns)
|
||||||
|
|
||||||
|
def complete(self, events, tools, model, system=None) -> list[Any]:
|
||||||
|
assert_renderable(events)
|
||||||
|
return self.turns.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_helpers_requests_end_on_the_user_side(world: dict[str, Any]) -> None:
|
||||||
|
helper = ContractHelper(
|
||||||
|
[AssistantText(text="Looking around first.", model="h")], # prose
|
||||||
|
[call("write_file", path="helped.py", content="H = 1\n")],
|
||||||
|
[hand_back()],
|
||||||
|
)
|
||||||
|
model = ScriptedModel(
|
||||||
|
RESTATE,
|
||||||
|
[
|
||||||
|
call("start_milestone", name="words"),
|
||||||
|
call("delegate", task="write helped.py"),
|
||||||
|
call("finish_milestone", summary="with help", final="yes"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
helpers = Helpers(
|
||||||
|
client=helper, # type: ignore[arg-type]
|
||||||
|
roles=ROLES,
|
||||||
|
client_for=lambda model: helper, # type: ignore[arg-type,return-value]
|
||||||
|
caps=Caps(turns=5),
|
||||||
|
)
|
||||||
|
agent, transport = loop_agent(world, model, helpers=helpers)
|
||||||
|
agent.intake()
|
||||||
|
say(transport, world, "go")
|
||||||
|
forge: Forge = world["forge"]
|
||||||
|
[pr] = forge.prs
|
||||||
|
assert forge.files(pr) == ["helped.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_in_a_thread_is_not_a_reply(tmp_path: Path) -> None:
|
||||||
|
clock = Clock()
|
||||||
|
model = ScriptedModel(RESTATED) # called once only: status runs nothing
|
||||||
|
h = Harness(
|
||||||
|
tmp_path,
|
||||||
|
model,
|
||||||
|
FakeGitea(issue(labels=("bug",))),
|
||||||
|
clock=clock,
|
||||||
|
bug_grace_s=600,
|
||||||
|
)
|
||||||
|
h.agent.intake()
|
||||||
|
grace = loop_of(h.item()).grace_until
|
||||||
|
h.say("status")
|
||||||
|
assert h.posted()[-1].startswith("#14 Add count_words: waiting for go-ahead")
|
||||||
|
loop = loop_of(h.item())
|
||||||
|
assert (h.item().position, loop.wait, loop.grace_until) == (
|
||||||
|
WAITING,
|
||||||
|
GO_AHEAD,
|
||||||
|
grace,
|
||||||
|
)
|
||||||
|
h.say("details")
|
||||||
|
assert loop_of(h.item()).grace_until == grace
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_remark_gets_a_reply_and_the_restatement_stays_up(tmp_path: Path) -> None:
|
||||||
|
h = Harness(
|
||||||
|
tmp_path,
|
||||||
|
ScriptedModel(
|
||||||
|
RESTATED,
|
||||||
|
[call("reply", text="Noted: I'll keep it to text.py.")],
|
||||||
|
),
|
||||||
|
FakeGitea(issue()),
|
||||||
|
)
|
||||||
|
h.agent.intake()
|
||||||
|
h.say("just a thought: keep it small")
|
||||||
|
loop = loop_of(h.item())
|
||||||
|
assert (loop.wait, loop.rev) == (GO_AHEAD, 1)
|
||||||
|
assert h.posted()[-1] == "Noted: I'll keep it to text.py."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_review_comment_can_be_answered_without_a_fix(
|
||||||
|
world: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
forge: Forge = world["forge"]
|
||||||
|
model = ScriptedModel(
|
||||||
|
RESTATE,
|
||||||
|
[
|
||||||
|
call("start_milestone", name="words"),
|
||||||
|
call("write_file", path="w.py", content="W = 1\n"),
|
||||||
|
call("finish_milestone", summary="words", final="yes"),
|
||||||
|
],
|
||||||
|
[call("reply", text="It's a constant on purpose, for the test.")],
|
||||||
|
)
|
||||||
|
agent, transport = loop_agent(world, model)
|
||||||
|
agent.intake()
|
||||||
|
say(transport, world, "go")
|
||||||
|
[pr] = forge.prs
|
||||||
|
forge.comments[pr] = [Comment(7, "don", "why a constant", "t")]
|
||||||
|
agent.watch()
|
||||||
|
agent._schedule()
|
||||||
|
loop = loop_of(the_item(world))
|
||||||
|
assert (loop.wait, loop.pr) == (MERGE, pr)
|
||||||
|
assert forge.posted[-1][0] == pr # answered on the PR, where asked
|
||||||
|
assert "It's a constant on purpose" in forge.posted[-1][1]
|
||||||
@@ -83,8 +83,15 @@ 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 and "propose_work" not in offered:
|
if "reply" in offered and not offered & {
|
||||||
|
"propose_work",
|
||||||
|
"restate",
|
||||||
|
"ask_human",
|
||||||
|
"start_milestone",
|
||||||
|
}:
|
||||||
return [call("reply", text=f"because {self.n}")] # a question run
|
return [call("reply", text=f"because {self.n}")] # a question run
|
||||||
|
if "reply" in offered and "propose_work" not in offered and roll < 0.2:
|
||||||
|
return [call("reply", text=f"noted {self.n}")] # a remark answered
|
||||||
if "reply" in offered: # a conversation: talk, or propose work
|
if "reply" in offered: # a conversation: talk, or propose work
|
||||||
if self.rng.random() < 0.1:
|
if self.rng.random() < 0.1:
|
||||||
return [
|
return [
|
||||||
@@ -258,6 +265,7 @@ class Walk:
|
|||||||
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({"plain-yes": 2, "wait": 2, "question": 2})
|
weights.update({"plain-yes": 2, "wait": 2, "question": 2})
|
||||||
|
weights.update({"status-here": 1})
|
||||||
if loop.new_project:
|
if loop.new_project:
|
||||||
weights.update({"done": 2})
|
weights.update({"done": 2})
|
||||||
if item.scope != "general": # conversations aren't named by number
|
if item.scope != "general": # conversations aren't named by number
|
||||||
@@ -273,6 +281,18 @@ class Walk:
|
|||||||
elif action == "done" and loop:
|
elif action == "done" and loop:
|
||||||
# The operator says a new project is ready; no entry was added.
|
# The operator says a new project is ready; no entry was added.
|
||||||
self.transport.receive(DON, "done", thread=loop.thread)
|
self.transport.receive(DON, "done", thread=loop.thread)
|
||||||
|
elif action == "status-here" and loop:
|
||||||
|
# L5: a command about the item, not a reply: nothing changes,
|
||||||
|
# not even a countdown.
|
||||||
|
before = (item.position, loop.wait, loop.rev, loop.grace_until)
|
||||||
|
self.transport.receive(DON, "status", thread=loop.thread)
|
||||||
|
after_item = self.world["store"].work_item(item.id)
|
||||||
|
after = loop_of(after_item)
|
||||||
|
now = (after_item.position, after.wait, after.rev, after.grace_until)
|
||||||
|
assert now == before, (
|
||||||
|
f"status_changes_nothing: {before} -> {now}: "
|
||||||
|
f"seed {self.seed}, steps {self.steps}"
|
||||||
|
)
|
||||||
elif action == "question" and loop:
|
elif action == "question" and loop:
|
||||||
# L5: a question never changes the item it's asked about.
|
# L5: a question never changes the item it's asked about.
|
||||||
before = (item.position, loop.wait, loop.rev, loop.pr, loop.branch)
|
before = (item.position, loop.wait, loop.rev, loop.pr, loop.branch)
|
||||||
|
|||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The bring-up's test repository (Development Plan, live runs):
|
||||||
|
# nisse-testing-operator/nisse-bringup, made as the end-to-end tests make
|
||||||
|
# theirs (tests/integration/e2e_support.py, make_repo), but kept between runs.
|
||||||
|
# Seeded with the test package, the bring-up agent's account a collaborator,
|
||||||
|
# main protected, and a deploy key of its own at $E2E/ssh/nisse-bringup.key.
|
||||||
|
#
|
||||||
|
# scripts/bringup-repo.sh create make it
|
||||||
|
# scripts/bringup-repo.sh reset delete it and make it again
|
||||||
|
# scripts/bringup-repo.sh delete delete it
|
||||||
|
#
|
||||||
|
# Needs NISSE_OPERATOR_TOKEN (.env here) and the bring-up agent's token
|
||||||
|
# (NISSE_GITEA_TOKEN in $E2E/.env), which names the agent's account.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
E2E=${NISSE_E2E:-/root/nisse-e2e}
|
||||||
|
case "${1:-}" in create|reset|delete) ;; *) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; esac
|
||||||
|
set -a; [ -f .env ] && . ./.env; set +a
|
||||||
|
exec uv run python - "$1" "$E2E" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, "tests/integration")
|
||||||
|
from e2e_support import OWNER, delete_repo, make_repo, operator_gitea # noqa: E402
|
||||||
|
|
||||||
|
from nisse_dev.envfile import load_env_file # noqa: E402
|
||||||
|
from nisse_dev.gitea import GiteaClient # noqa: E402
|
||||||
|
|
||||||
|
action, e2e = sys.argv[1], Path(sys.argv[2])
|
||||||
|
url = "https://gitea.dongar.net"
|
||||||
|
operator = operator_gitea(url)
|
||||||
|
name = "nisse-bringup"
|
||||||
|
if action in ("reset", "delete"):
|
||||||
|
delete_repo(operator, f"{OWNER}/{name}")
|
||||||
|
print(f"deleted {OWNER}/{name}")
|
||||||
|
if action in ("create", "reset"):
|
||||||
|
agent_env: dict[str, str] = {}
|
||||||
|
load_env_file(e2e / ".env", agent_env)
|
||||||
|
agent = GiteaClient(url, agent_env["NISSE_GITEA_TOKEN"]).whoami()
|
||||||
|
repo = make_repo(operator, name, e2e / "ssh", agent=agent)
|
||||||
|
print(f"made {repo.name}: {agent} a collaborator; push key {repo.push_key}")
|
||||||
|
PY
|
||||||
@@ -61,16 +61,14 @@ class FreshRepo:
|
|||||||
push_key: Path # this repository's deploy key, private half
|
push_key: Path # this repository's deploy key, private half
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
def make_repo(
|
||||||
def fresh_repo(
|
operator: GiteaClient, name: str, keys: Path, *, agent: str, seed: bool = True
|
||||||
operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
|
) -> FreshRepo:
|
||||||
) -> Iterator[FreshRepo]:
|
"""A repository as the operator would make a project's (D4, D8): seeded
|
||||||
"""A repository of its own for one test run, as the operator would make a
|
with a small Python package (or only a README), the ``agent`` account a
|
||||||
project's (D4, D8): seeded with a small Python package, the ``agent``
|
collaborator with write access, the default branch protected (pushes only
|
||||||
account a collaborator with write access, the default branch protected (pushes only by the
|
by the owner, an approval to merge), and a deploy key of its own, written
|
||||||
owner, an approval to merge), and a deploy key of its own, written under
|
under ``keys``. A failure part-way deletes what was made."""
|
||||||
``keys``. Deleted afterwards, unless NISSE_E2E_KEEP_REPOS is set."""
|
|
||||||
name = f"nisse-e2e-{run}"
|
|
||||||
full = f"{OWNER}/{name}"
|
full = f"{OWNER}/{name}"
|
||||||
operator._request(
|
operator._request(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -147,8 +145,35 @@ def fresh_repo(
|
|||||||
"required_approvals": 1,
|
"required_approvals": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
yield FreshRepo(full, key)
|
# The labels the agent reads (D1), as a project would have them.
|
||||||
|
for label, color in (
|
||||||
|
("bug", "#ee0701"),
|
||||||
|
("discuss", "#0e8a16"),
|
||||||
|
("design", "#5319e7"),
|
||||||
|
):
|
||||||
|
operator._request(
|
||||||
|
"POST", f"/repos/{full}/labels", json={"name": label, "color": color}
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
delete_repo(operator, full)
|
||||||
|
raise
|
||||||
|
return FreshRepo(full, key)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_repo(operator: GiteaClient, full: str) -> None:
|
||||||
|
with contextlib.suppress(GiteaError):
|
||||||
|
operator._request("DELETE", f"/repos/{full}")
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def fresh_repo(
|
||||||
|
operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
|
||||||
|
) -> Iterator[FreshRepo]:
|
||||||
|
"""A repository of its own for one test run (make_repo), deleted
|
||||||
|
afterwards unless NISSE_E2E_KEEP_REPOS is set."""
|
||||||
|
repo = make_repo(operator, f"nisse-e2e-{run}", keys, agent=agent, seed=seed)
|
||||||
|
try:
|
||||||
|
yield repo
|
||||||
finally:
|
finally:
|
||||||
if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
|
if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
|
||||||
with contextlib.suppress(GiteaError):
|
delete_repo(operator, repo.name)
|
||||||
operator._request("DELETE", f"/repos/{full}")
|
|
||||||
Reference in new issue
Block a user