What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
246 lines
9.6 KiB
Python
246 lines
9.6 KiB
Python
"""The queue in the new loop (M2.2): several items, parking and resuming on
|
|
switch, the operator's order, and items blocked by Gitea dependencies (L1,
|
|
D4). Real git on the throwaway build host; a fake Gitea."""
|
|
|
|
from typing import Any
|
|
|
|
from test_loop_milestones import DON, Forge, ScriptedModel, call, git, loop_agent
|
|
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
|
|
|
from nisse_core.gitea import Comment, Issue
|
|
from nisse_core.transport import InboundMessage, InMemoryTransport
|
|
from nisse_dev.loop.items import (
|
|
ANSWER,
|
|
BLOCKED,
|
|
GO_AHEAD,
|
|
MERGE,
|
|
READY,
|
|
WAITING,
|
|
item_for_issue,
|
|
loop_of,
|
|
)
|
|
|
|
|
|
def two_issues(forge: Forge) -> None:
|
|
forge.issues.append(Issue(15, "Add count_lines", "count lines", "don", ()))
|
|
|
|
|
|
def item(world: dict[str, Any], issue: int) -> Any:
|
|
found = item_for_issue(world["store"], issue)
|
|
assert found is not None
|
|
return found
|
|
|
|
|
|
def say(
|
|
transport: InMemoryTransport, world: dict[str, Any], issue: int, text: str
|
|
) -> None:
|
|
transport.receive(DON, text, thread=loop_of(item(world, issue)).thread)
|
|
|
|
|
|
def restated(transport: InMemoryTransport) -> list[str]:
|
|
"""The threads restatements went up in, in order."""
|
|
return [m.thread for m in transport.outbound if m.text.startswith("rev-1:")]
|
|
|
|
|
|
def test_switching_parks_the_work_and_resuming_finds_it(
|
|
world: dict[str, Any],
|
|
) -> None:
|
|
forge: Forge = world["forge"]
|
|
two_issues(forge)
|
|
model = ScriptedModel(
|
|
[call("restate", text="count_words")], # 14
|
|
[call("restate", text="count_lines")], # 15
|
|
[ # 14, after its go: a PR
|
|
call("start_milestone", name="count_words"),
|
|
call("write_file", path="words.py", content="W = 1\n"),
|
|
call("finish_milestone", summary="count_words"),
|
|
],
|
|
[ # 15, after its go: half done, then a question
|
|
call("start_milestone", name="count_lines"),
|
|
call("write_file", path="lines.py", content="L = 1\n"),
|
|
call("ask_human", question="one per line, or per paragraph?"),
|
|
],
|
|
[ # 14, a review comment
|
|
call("write_file", path="words.py", content="W = 2\n"),
|
|
call("finish_milestone", summary="review fix"),
|
|
],
|
|
[ # 15, answered: back where it was
|
|
call("run_command", command="cat lines.py"),
|
|
call("finish_milestone", summary="count_lines"),
|
|
],
|
|
)
|
|
agent, transport = loop_agent(world, model)
|
|
agent.intake()
|
|
assert [loop_of(item(world, n)).wait for n in (14, 15)] == [GO_AHEAD, GO_AHEAD]
|
|
# The second item is acknowledged with its place in line.
|
|
assert any("#15" in m.text and "1 ahead" in m.text for m in transport.outbound)
|
|
say(transport, world, 14, "go")
|
|
assert loop_of(item(world, 14)).wait == MERGE
|
|
a_branch = loop_of(item(world, 14)).branch
|
|
say(transport, world, 15, "go")
|
|
assert loop_of(item(world, 15)).wait == ANSWER
|
|
# 15 was prepared while 14's branch was on the workspace: kept, not set aside.
|
|
assert not any("Set aside" in m.text for m in transport.outbound)
|
|
assert a_branch in world["repo"].local_branches()
|
|
|
|
[pr] = forge.prs
|
|
forge.comments[pr] = [Comment(7, "don", "make W 2", "t")]
|
|
agent.watch()
|
|
agent._schedule()
|
|
|
|
# Switching to 14 parked 15: its half-done work is pushed as a stash to
|
|
# a side branch (new files in its third parent), never onto 15's branch.
|
|
b_branch = loop_of(item(world, 15)).branch
|
|
bare = ["--git-dir", str(world["bare"])]
|
|
parked = f"refs/heads/{item(world, 15).id}-parked"
|
|
assert git(*bare, "show", f"{parked}^3:lines.py") == "L = 1"
|
|
# The branch has no commits of its own, so it wasn't pushed at all.
|
|
assert b_branch not in git(*bare, "branch", "--list")
|
|
# 14's fix went to its own branch, without 15's work.
|
|
assert loop_of(item(world, 14)).wait == MERGE
|
|
files = git("--git-dir", str(world["bare"]), "ls-tree", "--name-only", a_branch)
|
|
assert "words.py" in files.split() and "lines.py" not in files.split()
|
|
assert "W = 2" in git(
|
|
"--git-dir", str(world["bare"]), "show", f"refs/heads/{a_branch}:words.py"
|
|
)
|
|
|
|
say(transport, world, 15, "one per line")
|
|
assert world["repo"].current_branch() == b_branch
|
|
assert (world["repo"].root / "lines.py").read_text() == "L = 1\n"
|
|
# Put back, so the side branch is gone.
|
|
assert item(world, 15).id + "-parked" not in git(*bare, "branch", "--list")
|
|
assert loop_of(item(world, 15)).wait == MERGE
|
|
assert len(forge.prs) == 2
|
|
|
|
|
|
def test_a_blocked_item_waits_for_its_dependency_to_close(
|
|
world: dict[str, Any],
|
|
) -> None:
|
|
forge: Forge = world["forge"]
|
|
two_issues(forge)
|
|
forge.deps[15] = [14]
|
|
model = ScriptedModel(
|
|
[call("restate", text="count_words")],
|
|
[call("restate", text="count_lines")],
|
|
)
|
|
agent, transport = loop_agent(world, model)
|
|
agent.intake()
|
|
assert item(world, 15).position == BLOCKED
|
|
assert loop_of(item(world, 15)).blocked_by == ("don/sandbox#14",)
|
|
assert any(
|
|
"#15" in m.text and "blocked by don/sandbox#14" in m.text
|
|
for m in transport.outbound
|
|
)
|
|
assert "#15 Add count_lines: blocked by don/sandbox#14" in agent.status()
|
|
# Talking to it doesn't start it.
|
|
say(transport, world, 15, "go")
|
|
assert item(world, 15).position == BLOCKED
|
|
assert "blocked" in transport.outbound[-1].text
|
|
|
|
forge.closed_issues.append(14)
|
|
agent.intake()
|
|
assert (item(world, 15).position, loop_of(item(world, 15)).wait) == (
|
|
WAITING,
|
|
GO_AHEAD,
|
|
)
|
|
assert loop_of(item(world, 15)).blocked_by == ()
|
|
|
|
|
|
def test_an_item_that_gains_a_dependency_is_blocked(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
two_issues(forge)
|
|
model = ScriptedModel([call("restate", text="count_words")])
|
|
agent, _ = loop_agent(world, model)
|
|
agent._running = True # hold the queue: both stay ready
|
|
agent.intake()
|
|
assert item(world, 15).position == READY
|
|
forge.deps[15] = [14]
|
|
agent.check_blocked()
|
|
assert item(world, 15).position == BLOCKED
|
|
agent._running = False
|
|
agent._schedule() # 14 restates; 15 stays blocked
|
|
assert item(world, 15).position == BLOCKED
|
|
assert loop_of(item(world, 14)).wait == GO_AHEAD
|
|
|
|
|
|
def test_the_operators_order_comes_before_the_oldest(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
two_issues(forge)
|
|
forge.issues.append(Issue(16, "Add count_chars", "count chars", "don", ()))
|
|
forge.issues.append(Issue(17, "Add count_vowels", "count vowels", "don", ()))
|
|
for n in (15, 16, 17):
|
|
forge.deps[n] = [14]
|
|
model = ScriptedModel(*[[call("restate", text=f"r{n}")] for n in range(4)])
|
|
agent, transport = loop_agent(world, model)
|
|
agent.intake() # 14 runs; the rest wait on it
|
|
transport.receive(DON, "Do #17 next.")
|
|
assert "#17 goes first once it's unblocked" in transport.outbound[-1].text
|
|
transport.receive(DON, "do #99 next")
|
|
assert "#99 isn't one of my open work items" in transport.outbound[-1].text
|
|
|
|
forge.closed_issues.append(14)
|
|
agent.intake()
|
|
threads = {loop_of(item(world, n)).thread: n for n in (14, 15, 16, 17)}
|
|
assert [threads[t] for t in restated(transport)] == [14, 17, 15, 16]
|
|
|
|
|
|
def test_status_shows_the_line(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
two_issues(forge)
|
|
agent, _ = loop_agent(world, ScriptedModel())
|
|
agent._running = True # hold the queue
|
|
agent.intake()
|
|
assert "#14 Add count_words: ready, 1 of 2 in line" in agent.status()
|
|
assert "#15 Add count_lines: ready, 2 of 2 in line" in agent.status()
|
|
agent.handle(InboundMessage(DON, "do #15 next"), None)
|
|
assert "#15 Add count_lines: ready, 1 of 2 in line" in agent.status()
|
|
|
|
|
|
def test_a_fix_half_done_when_its_pr_merges_survives_a_switch(
|
|
world: dict[str, Any],
|
|
) -> None:
|
|
"""Found by the walk: work on a review fix, parked after the PR merged,
|
|
was pushed onto the merged branch, where nobody would see it. Now it
|
|
comes back with the item and goes out in its next PR."""
|
|
forge: Forge = world["forge"]
|
|
two_issues(forge)
|
|
model = ScriptedModel(
|
|
[call("restate", text="count_words")], # 14
|
|
[call("restate", text="count_lines")], # 15
|
|
[ # 14: a PR
|
|
call("start_milestone", name="words"),
|
|
call("write_file", path="words.py", content="W = 1\n"),
|
|
call("finish_milestone", summary="words", final="yes"),
|
|
],
|
|
[ # 14, a review comment: half a fix, then a question
|
|
call("write_file", path="fix.py", content="F = 1\n"),
|
|
call("ask_human", question="fix it in words.py too?"),
|
|
],
|
|
[ # 15 runs meanwhile
|
|
call("start_milestone", name="lines"),
|
|
call("write_file", path="lines.py", content="L = 1\n"),
|
|
call("finish_milestone", summary="lines"),
|
|
],
|
|
[call("finish_milestone", summary="the fix", final="yes")], # 14 again
|
|
)
|
|
agent, transport = loop_agent(world, model)
|
|
agent.intake()
|
|
say(transport, world, 14, "go")
|
|
[pr] = forge.prs
|
|
forge.comments[pr] = [Comment(7, "don", "add fix.py", "t")]
|
|
agent.watch()
|
|
agent._schedule()
|
|
assert loop_of(item(world, 14)).wait == ANSWER
|
|
say(transport, world, 15, "go") # parks 14's half-done fix
|
|
forge.merge(pr)
|
|
agent.watch()
|
|
agent._schedule() # 14 resumes; its fix goes out as a new PR
|
|
|
|
bare = ["--git-dir", str(world["bare"])]
|
|
merged_head = forge.prs[pr]["head"]
|
|
assert "fix.py" not in git(*bare, "ls-tree", "--name-only", merged_head).split()
|
|
newest = max(
|
|
n for n, p in forge.prs.items() if p["head"].startswith(item(world, 14).id)
|
|
)
|
|
assert newest != pr and forge.files(newest) == ["fix.py"]
|