Merge main into agent-host-vault
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m0s
CI / e2e (pull_request) Successful in 6m27s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-19 22:10:22 +00:00
commit 7cd2de6ab6
7 files changed
+252 -36

No files matched your search

+33 -13
View File
@@ -39,8 +39,8 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
| **D1–D2** Workflow | partial | On the new loop: issue intake, restatement and go-ahead (M2.1), labels (M2.3); no documents in the repo | | **D1–D2** Workflow | partial | On the new loop: issue intake, restatement and go-ahead (M2.1), labels (M2.3); no documents in the repo |
| **D3–D4** Workspace, git | partial | Idempotent publishing; no fresh workspace (#62), `git add -A` (#80) | | **D3–D4** Workspace, git | partial | Idempotent publishing; no fresh workspace (#62), `git add -A` (#80) |
| **D5** Review | partial | PR conversation comments only (#69), watch stall (#70) | | **D5** Review | partial | PR conversation comments only (#69), watch stall (#70) |
| **D7–D8** Deployment, projects | partial | Runs from a venv as a systemd unit on this box; project entries in config (M1.4); egress not enforced | | **D7–D8** Deployment, projects | partial | Bring-up runs `nisse-dev`'s image under systemd on this box, `nisse-review` from a venv; project entries in config (M1.4); egress not enforced |
| **O**, **X** | none | No container image yet | | **O**, **X** | partial | `nisse-dev` has its image (M1.5); `nisse-review` doesn't yet (M7.6); nothing runs on the agent host |
| `specs/` | done for now | `loop.qnt` (the architecture, #91); `orchestrator.qnt` went with `control.py` (M2.8) | | `specs/` | done for now | `loop.qnt` (the architecture, #91); `orchestrator.qnt` went with `control.py` (M2.8) |
--- ---
@@ -86,6 +86,11 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
(`-k`) or diagnosed (`--lf`); `scripts/e2e.sh` reports what a run spent. Manual (`-k`) or diagnosed (`--lf`); `scripts/e2e.sh` reports what a run spent. Manual
live runs are for the new loop's first runs and phase ends. Findings are filed as live runs are for the new loop's first runs and phase ends. Findings are filed as
issues and either fixed before moving on or deliberately deferred. issues and either fixed before moving on or deliberately deferred.
- **Every agent is its own container image** (X1), since that's how each one
runs on the agent host: built from the repository's `Dockerfile`, published by
`release.yml` to `gitea.dongar.net/dev/<agent>`, and tested over the image the
way `nisse-dev`'s is (M1.5). A new agent's phase includes its image, before the
agent host is ready for it, and its bring-up runs from that image.
- **The Quint model moves with the loop:** `loop.qnt` models the architecture, - **The Quint model moves with the loop:** `loop.qnt` models the architecture,
and its properties are tests (the property walk), changed in the same PR as a and its properties are tests (the property walk), changed in the same PR as a
rule they cover. rule they cover.
@@ -556,10 +561,12 @@ SilverBullet so far (M5.4). Until M6.2, the build VMs stay made by hand, with
`vm ready` for a rebuilt one's host key. `vm ready` for a rebuilt one's host key.
**M6.1 The agent host.** *Needs discussion.* Containers, volumes, snapshots, **M6.1 The agent host.** *Needs discussion.* Containers, volumes, snapshots,
per-agent networks (X1, X2, X4). The host exists: `nisse` (infra/tofu#77, per-agent networks (X1, X2, X4). Each agent runs there from its own published
2026-10-18, as `nisse-host`; 2 cores, 4 GB, 100 GB, on VLAN 1, nightly Proxmox image (§3). The host exists: `nisse` (infra/tofu#77, 2026-10-18, as
backups), requested through nisse-dev's own quota to test that path; the operator `nisse-host`; 2 cores, 4 GB, 100 GB, on VLAN 1, nightly Proxmox backups),
has since renamed it and moved it out of the quota. It holds only the vault and SilverBullet so far (M5.4). requested through nisse-dev's own quota to test that path; the operator has
since renamed it and moved it out of the quota. It holds only the vault and
SilverBullet so far (M5.4).
**M6.2 Build VMs by Terraform.** *Needs discussion.* The template, VLAN and egress **M6.2 Build VMs by Terraform.** *Needs discussion.* The template, VLAN and egress
(D7, X2), collection when idle. (D7, X2), collection when idle.
@@ -695,8 +702,11 @@ of earlier points, replies on threads, the round cap and its reset, `stop` and
can't be listed, and its earlier points with what was said on their lines. can't be listed, and its earlier points with what was said on their lines.
- **Replies:** `reply` answers a point where it was made (inline on its line, - **Replies:** `reply` answers a point where it was made (inline on its line,
else quoting it), `skip` leaves it with a reason, `done` ends; every point is else quoting it), `skip` leaves it with a reason, `done` ends; every point is
answered or skipped before a run may end. Each reply carries its point's key, answered or skipped before a run may end. An ending call refused for that is
so a run re-done after a crash answers nothing twice. held, and goes ahead as soon as the last point is settled, rather than left
to the model to make again (on #166 it didn't, and looped to the turn cap).
Each reply carries its point's key, so a run re-done after a crash answers
nothing twice.
- **Sign-off:** `sign_off` posts a short review when there is nothing to raise; - **Sign-off:** `sign_off` posts a short review when there is nothing to raise;
while it stays happy, a later review offers `still_happy`, which posts nothing while it stays happy, a later review offers `still_happy`, which posts nothing
(operator: no repeated LGTMs). (operator: no repeated LGTMs).
@@ -736,9 +746,18 @@ of earlier points, replies on threads, the round cap and its reset, `stop` and
L6 above. Each cut fitting still makes is logged (`context.trimmed`). L6 above. Each cut fitting still makes is logged (`context.trimmed`).
Tests: `test_compact.py` in nisse-core. Tests: `test_compact.py` in nisse-core.
**M7.6 Deployment.** *Waits for the agent host (Phase 6).* Its image, container, **M7.6 `nisse-review`'s image.** *Pre-approved.* Doesn't wait for the agent
Gitea account and secrets (R5). The image needs `git`, and `scratch/` a host. Its own image, as `nisse-dev`'s (M1.5, §3): a `nisse-review` target in the
persistent volume, so clones outlive a restart. `Dockerfile`, running as its own user, with state in a volume, configuration
mounted read-only and secrets as environment variables. The image needs `git`,
and `scratch/` a persistent volume, so clones outlive a restart. `release.yml`
publishes `gitea.dongar.net/dev/nisse-review` beside `nisse-dev`, and
`review-bringup.sh` runs the image instead of a virtualenv.
- Acceptance: a release publishes both images, and the review end-to-end tests
run over the published `nisse-review` image.
**M7.7 Deployment.** *Waits for the agent host (Phase 6).* Its container on the
agent host, Gitea account and secrets (R5).
### Phase 8 — `nisse-ops` ### Phase 8 — `nisse-ops`
@@ -747,8 +766,9 @@ credentials (L12), with masking what an API returns as #144; search-then-dispatc
meta-tools are avoided, not ruled out (X3). **M8.2 Approved step sequences** (O2). meta-tools are avoided, not ruled out (X3). **M8.2 Approved step sequences** (O2).
**M8.3 Ops intake and duties** from the infrastructure repository (O5), taking over **M8.3 Ops intake and duties** from the infrastructure repository (O5), taking over
the Terraform the operator is writing now. **M8.4 The ops tool VM and deployment** the Terraform the operator is writing now. **M8.4 The ops tool VM and deployment**
(O6). **M8.5 Requests between agents:** `nisse-dev` asking for build VMs (L1, O5). (O6); its own image comes with its first slice, as every agent's does (§3).
All *needs discussion*. **M8.5 Requests between agents:** `nisse-dev` asking for build VMs (L1, O5). All
*needs discussion*.
### Not yet scheduled ### Not yet scheduled
+22 -1
View File
@@ -108,6 +108,7 @@ class Driver:
on_turn: Callable[[str], None] | None = None, on_turn: Callable[[str], None] | None = None,
screen: Callable[[ToolCall], Screening] | None = None, screen: Callable[[ToolCall], Screening] | None = None,
max_turns: int | None = None, max_turns: int | None = None,
due: Callable[[], tuple[str, dict[str, Any], str] | None] | None = None,
) -> RunOutcome: ) -> RunOutcome:
"""``waiting``: the control tools that end the run, for the """``waiting``: the control tools that end the run, for the
scheduler to act on. ``handlers`` answer those that don't (e.g. scheduler to act on. ``handlers`` answer those that don't (e.g.
@@ -115,7 +116,10 @@ class Driver:
``checks`` vet a waiting tool's arguments: a problem is the call's ``checks`` vet a waiting tool's arguments: a problem is the call's
failed result and the run goes on, so the model puts it right. failed result and the run goes on, so the model puts it right.
``screen`` judges each execution call before it reaches the tool VM ``screen`` judges each execution call before it reaches the tool VM
(P4).""" (P4). ``due``, asked after each handled call, names a waiting call
the harness held back that may now go ahead (tool, arguments, what
to tell the model): the run ends with it, as if the model had made
it again."""
handlers = handlers or {} handlers = handlers or {}
checks = checks or {} checks = checks or {}
model = model or self._model # the work item's pinned driver (L9) model = model or self._model # the work item's pinned driver (L9)
@@ -193,6 +197,23 @@ class Driver:
result, ok = handlers[call.tool](dict(call.arguments)) result, ok = handlers[call.tool](dict(call.arguments))
except VMUnreachable as exc: except VMUnreachable as exc:
return self._unreachable(transcript, calls, index, str(exc)) return self._unreachable(transcript, calls, index, str(exc))
held = due() if ok and due is not None else None
if held is not None:
tool, arguments, said = held
transcript.append(
ToolResult(
call_id=call.call_id,
result=f"{result}\n\n[harness] {said}; your turn "
"has ended",
success=True,
)
)
skip_calls(
transcript,
calls[index + 1 :],
"not run: the turn had ended",
)
return RunOutcome("wait", tool, arguments)
elif call.tool not in offered or workspace is None: elif call.tool not in offered or workspace is None:
result, ok = ( result, ok = (
f"no such tool: {call.tool}; the tools are " f"no such tool: {call.tool}; the tools are "
+69
View File
@@ -81,3 +81,72 @@ def test_a_tool_not_named_waiting_doesnt_end_the_run(tmp_path: Path) -> None:
) )
assert ran.tool == "post_review" assert ran.tool == "post_review"
assert results(log)[0].startswith("no such tool: ask_human") assert results(log)[0].startswith("no such tool: ask_human")
NOTE = tool("note", "Note something.", text="The note.")
def test_a_held_call_goes_ahead_once_due_says_so(tmp_path: Path) -> None:
# post_review is refused while a point is open and held by the agent;
# the handled call that settles the point ends the run with it.
open_points = ["P1"]
held: dict[str, Any] = {}
def refuse(args: dict[str, Any]) -> str | None:
if open_points:
held["call"] = ("post_review", args)
return "answer P1 first"
return None
def settle(args: dict[str, Any]) -> tuple[str, bool]:
open_points.clear()
return "answered P1", True
def due() -> tuple[str, dict[str, Any], str] | None:
if "call" in held and not open_points:
tool_, args = held.pop("call")
return tool_, args, "your held post_review went ahead"
return None
model = Scripted(
[call("post_review", body="the summary")],
[call("note", text="P1"), call("read_file", path="a.py")],
# never reached: the run ended on the note
[call("note", text="again")],
)
log = transcript(tmp_path)
reader = Reader()
ran = Driver(model, "m").run(
log,
system="s",
tools=[READ, POST, NOTE],
waiting={"post_review"},
workspace=reader,
handlers={"note": settle},
checks={"post_review": refuse},
due=due,
)
assert (ran.kind, ran.tool, ran.arguments) == (
"wait",
"post_review",
{"body": "the summary"},
)
assert results(log) == [
"answer P1 first",
"answered P1\n\n[harness] your held post_review went ahead; your turn has ended",
"not run: the turn had ended",
]
assert reader.calls == [] # the read after it never ran
def test_without_due_a_settled_point_leaves_the_run_going(tmp_path: Path) -> None:
model = Scripted([call("note", text="x")], [call("post_review", body="b")])
ran = Driver(model, "m").run(
transcript(tmp_path),
system="s",
tools=[POST, NOTE],
waiting={"post_review"},
workspace=None,
handlers={"note": lambda args: ("noted", True)},
)
assert (ran.tool, ran.arguments) == ("post_review", {"body": "b"})
+1 -1
View File
@@ -3056,7 +3056,7 @@ class LoopAgent:
"""Block a build VM (D3): every project on it waits until it is """Block a build VM (D3): every project on it waits until it is
recovered. Said once at top level, and in each waiting item's thread. recovered. Said once at top level, and in each waiting item's thread.
The recovery is the operator's for now, or the VM's coming back; The recovery is the operator's for now, or the VM's coming back;
nisse-ops' later (M7.6).""" nisse-ops' later (M8.5)."""
if host in self._store.vm_blocks(): if host in self._store.vm_blocks():
return # already said return # already said
self._store.block_vm( self._store.block_vm(
+59 -19
View File
@@ -18,7 +18,7 @@ from nisse_core.knowledge.handlers import Knowledge
from nisse_core.log.canonical import Fact from nisse_core.log.canonical import Fact
from nisse_core.loop.driver import Driver, RunOutcome from nisse_core.loop.driver import Driver, RunOutcome
from nisse_core.loop.expert import BudgetExhausted, ExpertDesk from nisse_core.loop.expert import BudgetExhausted, ExpertDesk
from nisse_core.loop.tools import ASK_EXPERT, tool from nisse_core.loop.tools import ASK_EXPERT, names, tool
from nisse_core.models.llm import LLMError from nisse_core.models.llm import LLMError
from nisse_core.oplog import OpLog from nisse_core.oplog import OpLog
from nisse_core.state.store import Transcript from nisse_core.state.store import Transcript
@@ -168,6 +168,10 @@ class Draft:
points: dict[str, Said] = field(default_factory=dict) # P1 -> the point points: dict[str, Said] = field(default_factory=dict) # P1 -> the point
replied: list[str] = field(default_factory=list) # keys answered replied: list[str] = field(default_factory=list) # keys answered
skipped: list[str] = field(default_factory=list) # keys left, with reason skipped: list[str] = field(default_factory=list) # keys left, with reason
# An ending call refused only because points were open: it goes ahead
# once the last is answered or skipped, not left to the model to repeat.
held: tuple[str, dict[str, Any]] | None = None
ends: tuple[str, ...] = () # the run's ending tools, for the messages
def open_points(self) -> list[str]: def open_points(self) -> list[str]:
settled = {*self.replied, *self.skipped} settled = {*self.replied, *self.skipped}
@@ -283,19 +287,53 @@ class Reviewer:
elif kind == LATER: elif kind == LATER:
tools.append(STILL_HAPPY) tools.append(STILL_HAPPY)
def answered_first(args: dict[str, Any]) -> str | None: draft.ends = tuple(t for t in names(tools) if t in WAITING)
left = draft.open_points()
if left:
return "first answer or skip each point said to you: " + ", ".join(left)
return None
def no_comments(args: dict[str, Any]) -> str | None: def answered_first(tool: str) -> Callable[[dict[str, Any]], str | None]:
if draft.comments: def check(args: dict[str, Any]) -> str | None:
left = draft.open_points()
if not left:
return None
draft.held = (tool, args)
return ( return (
f"you drafted {len(draft.comments)} comment(s): post them " "first answer or skip each point said to you: "
"with post_review" + ", ".join(left)
+ f". Your {tool} is held: it goes ahead once the last is "
"answered or skipped"
) )
return answered_first(args)
return check
def no_comments(tool: str) -> Callable[[dict[str, Any]], str | None]:
def check(args: dict[str, Any]) -> str | None:
if draft.comments:
return (
f"you drafted {len(draft.comments)} comment(s): post them "
"with post_review"
)
return answered_first(tool)(args)
return check
checks = {
"post_review": answered_first("post_review"),
"sign_off": no_comments("sign_off"),
"still_happy": no_comments("still_happy"),
"done": answered_first("done"),
}
def due() -> tuple[str, dict[str, Any], str] | None:
if draft.held is None or draft.open_points():
return None
tool, args = draft.held
draft.held = None
if checks[tool](args) is not None:
return None # wrong for another reason now: the model's to fix
return (
tool,
args,
f"that was the last open point, so your held {tool} went ahead",
)
outcome = self._driver.run( outcome = self._driver.run(
transcript, transcript,
@@ -304,13 +342,9 @@ class Reviewer:
waiting=WAITING, waiting=WAITING,
workspace=repo, workspace=repo,
handlers=handlers, handlers=handlers,
checks={ checks=checks,
"post_review": answered_first,
"sign_off": no_comments,
"still_happy": no_comments,
"done": answered_first,
},
max_turns=self._max_turns, max_turns=self._max_turns,
due=due,
) )
return outcome, draft return outcome, draft
@@ -535,8 +569,14 @@ class Reviewer:
wanted = f"P{wanted}" wanted = f"P{wanted}"
if wanted in draft.open_points(): if wanted in draft.open_points():
return wanted, draft.points[wanted] return wanted, draft.points[wanted]
left = ", ".join(draft.open_points()) or "none" left = draft.open_points()
return f"no open point {wanted or '(none given)'}; open: {left}" if not left:
return (
f"no open point {wanted or '(none given)'}: every point said to "
"you is answered or skipped. End your run with "
+ " or ".join(draft.ends or ("your ending tool",))
)
return f"no open point {wanted or '(none given)'}; open: {', '.join(left)}"
def _comment( def _comment(
self, repo: Repository, draft: Draft, args: dict[str, Any] self, repo: Repository, draft: Draft, args: dict[str, Any]
+66
View File
@@ -409,3 +409,69 @@ def test_review_with_a_comment_beside_it_is_one_fresh_review(tmp_path: Path) ->
known = pr_of(item(w)) known = pr_of(item(w))
assert (known.full, known.fresh, item(w).position) == (False, (), REVIEWED) assert (known.full, known.fresh, item(w).position) == (False, (), REVIEWED)
assert w["scheduler"].round() is False # nothing more due assert w["scheduler"].round() is False # nothing more due
# --- a held ending call (the run on #166 at bf98445) --------------------------------
def later_with_a_point(tmp_path: Path, *turns: list[Any]) -> dict[str, Any]:
"""A push and an answer on its line since its first review: a later run
with P1 to answer."""
w = reviewed(tmp_path, *turns)
push(w)
w["gitea"].note("nisse-dev", "split() is fine: no tabs here.", "count.py:2")
return w
def test_a_review_refused_for_an_open_point_posts_once_it_is_answered(
tmp_path: Path,
) -> None:
# The model posted with P1 open, answered it, then only skipped answered
# points until the turn cap: the review went out partial, its summary lost.
loop = [[call("skip", point="P1", reason="already answered")]] * 5
w = later_with_a_point(
tmp_path,
[call("post_review", summary="Both commits do what they say.")],
[call("reply", point="P1", text="Fair: conceded.")],
*loop,
)
assert w["scheduler"].round() is True
review = w["gitea"].posted[-1]
assert "Both commits do what they say." in review["body"]
assert "partial" not in review["body"].lower()
assert item(w).position == REVIEWED
model = w["scheduler"]._reviewer._driver._client
assert len(model._turns) == 5 # none of the skips ran
told = str(model.seen[-1])
assert "Your post_review is held" in told
def test_a_held_sign_off_waits_if_comments_were_drafted_since(tmp_path: Path) -> None:
# Held while P1 was open, but a drafted comment needs post_review now:
# the harness doesn't sign off over it; the model posts instead.
w = later_with_a_point(
tmp_path,
[call("sign_off", summary="All good.")],
[call("comment", path="count.py", line="2", text="One more thing.")],
[call("reply", point="P1", text="Agreed.")],
[call("post_review", summary="One more point.")],
)
assert w["scheduler"].round() is True
review = w["gitea"].posted[-1]
assert "One more point." in review["body"]
assert ["One more thing." in c.body for c in review["comments"]] == [True]
def test_with_nothing_open_a_skip_says_how_to_end(tmp_path: Path) -> None:
w = later_with_a_point(
tmp_path,
[call("reply", point="P1", text="Agreed.")],
[call("skip", point="P1", reason="again")],
[call("post_review", summary="Done.")],
)
w["scheduler"].round()
model = w["scheduler"]._reviewer._driver._client
assert "every point said to you is answered or skipped. End your run with" in str(
model.seen[-1]
)
assert "post_review" in str(model.seen[-1]).split("End your run with")[1]
+2 -2
View File
@@ -2,8 +2,8 @@
# Bring-up runs of nisse-review on this development VM (Development Plan M7.3). # Bring-up runs of nisse-review on this development VM (Development Plan M7.3).
# #
# It runs from the exported code under test, in a virtualenv of its own, as a # It runs from the exported code under test, in a virtualenv of its own, as a
# transient systemd unit, so it outlives the shell that started it. Its # transient systemd unit, so it outlives the shell that started it. It runs
# container comes with M7.6. # from its image after M7.6, and on the agent host after M7.7.
# #
# scripts/review-bringup.sh start [REF] export REF (default HEAD), install # scripts/review-bringup.sh start [REF] export REF (default HEAD), install
# it, and start the reviewer # it, and start the reviewer