Compare commits

..
9 Commits
Author SHA1 Message Date
dev-agent 07a2eb9d10 Merge pull request 'Wait out LLM outages with backoff (L5)' (#116) from llm-retry into main
CI / e2e (push) Skipped
CI / checks (push) Skipped
Release / build-and-push (release) Successful in 19s
2026-10-07 22:29:40 +00:00
dev-agent cd9eb3fd76 Merge pull request 'The agent as a container image (M1.5)' (#115) from image into main
CI / checks (push) Successful in 5m44s
CI / e2e (push) Skipped
2026-10-07 21:48:43 +00:00
dongarandClaude Opus 5.5 ad8a07fcb7 Wait out LLM outages with backoff (L5: outages delay, never fail)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 6m14s
CI / e2e (pull_request) Successful in 30m8s
A 502, 503 or 504 from the LiteLLM proxy, or a dropped connection, is
retried after 5, 15 and 45 seconds before the call fails. Twice on
2026-10-07 a short burst of 502s stopped work outright: the integration
tier at 16:39 and an e2e conversation at 20:43, where the item waited on
the operator after one refused call.

Not retried: a 500 (it may be the request's fault), a timeout (it already
had its request_timeout_s), or a budget refusal. Rate limits keep their
single 20-second retry. The time spent waiting is reported with the rate
limit's (last_rate_limit_wait_s), so it isn't counted as the model's.

Tests: an outage waited out, one that outlasts the backoff, a dropped
connection, a 500 and a timeout not retried, a budget refusal not waited
on. The offline agent in test_agent_build skips the backoff (its proxy
doesn't exist).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 21:11:45 +00:00
dongarandClaude Opus 5.5 6af54790a9 CI: no image build on PRs; images are built only on release
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 12m10s
CI / e2e (pull_request) Successful in 34m0s
The build-only image job on every PR goes: images are built when a
release is published (release.yml), so PRs don't wait on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 21:00:21 +00:00
dongarandClaude Opus 5.5 2766cf1e8e Release: the dev organization's registry, pushed by registry-push
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 6m28s
CI / image (pull_request) Successful in 21s
CI / e2e (pull_request) Canceled after 1m27s
The repository moved to the dev organization: images go to
gitea.dongar.net/dev/nisse-dev, pushed by the registry-push bot with the
organization's REGISTRY_TOKEN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 20:24:51 +00:00
dongarandClaude Opus 5.5 4d42040a34 Release: build on a published release, as in inlaw_outlaw
- on: release (published), not on any v* tag: only a deliberate release
  builds an image; the version is its tag without the v, plus latest.
- docker/login-action, setup-buildx and build-push-action, as the runner
  already serves them for inlaw_outlaw; the version as a build argument
  and image label.
- Comments on why the registry needs a personal token (the job token's
  empty package scope) and that packages belong to the repository owner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 20:14:56 +00:00
dongarandClaude Opus 5.5 0660fa3c51 The agent as a container image (M1.5)
CI / checks (push) Skipped
CI / e2e (pull_request) Failing after 49m48s
CI / checks (pull_request) Successful in 10m0s
CI / image (pull_request) Successful in 27s
- Dockerfile: built with uv into /opt/nisse (installed, not editable),
  run on python:3.12-slim with openssh-client as user nisse; stateless,
  with state in a /var/lib/nisse-dev volume, config mounted at
  /etc/nisse-dev/nisse-dev.yaml, secrets as environment variables, keys
  mounted read-only. 202 MB. Its `test` stage adds the tests and their
  tools over the installed package (UV_NO_SYNC, so `uv run` never swaps
  in the source tree); the default target is the agent.
- scripts/e2e.sh --image REF runs the suite inside an image's test stage,
  on its own sshd.
- CI builds both images on every PR (nothing pushed); release.yml pushes
  gitea.dongar.net/dongar/nisse-dev:<tag> and :latest on a v* tag, with
  the REGISTRY_TOKEN secret.
- scripts/bringup.sh builds the code under test into an image and runs it
  in the systemd unit (root, $E2E mounted at its own path); `plan` prints
  what it would run; the build-VM set-aside reads the projects: config.

Checked: the image refuses to start without a config, runs as uid 10001;
one e2e test passed from the test stage (2m45s, $0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 19:50:59 +00:00
dev-agent 2d6aa98f87 Merge pull request 'Conversation on the new loop (M2.7)' (#114) from conversation into main
CI / checks (push) Successful in 7m0s
CI / e2e (push) Skipped
2026-10-07 19:20:35 +00:00
dongarandClaude Opus 5.5 8994b3fb4b Conversation on the new loop (M2.7)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 13m26s
CI / e2e (pull_request) Successful in 38m44s
- Questions never change state (L5, #77): a message ending in "?" to a
  waiting project item starts a question run (reply and look-only tools,
  an instruction to change nothing), and the item goes back to the very
  wait it was in: a restatement up stays up and a go binds to it; the
  agent's own question stays open.
- Explanations: a restatement gives its reasons and the main alternative;
  a revision says what changed (restate(changed), sent back without it);
  a milestone's summary says what was done, verified and left open.
- Progress in prose (#42): the driver's and a helper's own words for a
  turn are posted, trimmed, at most once a minute per item; `details`
  shows the last tool calls; a message arriving mid-run is acknowledged at
  once ("Noted; ...").
- CI: the e2e job's comments and the plan no longer claim a newer push
  cancels the run it supersedes (Gitea here doesn't); the concurrency
  block goes.

Tests: conversation unit tests (questions in a go-ahead and an answer wait,
an unanswerable question, revisions, progress rate and trimming, details,
mid-run notes), the helper's progress, the walk asking questions in every
state with a questions_change_nothing property (routing questions the old
way fails 32 of 48 seeds), and an e2e question while a restatement is up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 18:01:51 +00:00
22 changed files with 787 additions and 58 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
.git
.env
**/.venv
**/__pycache__
**/.pytest_cache
**/.ruff_cache
_apalache-out
tmp
bench
+5 -8
View File
@@ -58,18 +58,15 @@ jobs:
e2e:
# The end-to-end suite (scripts/e2e.sh): the whole agent against real
# services, every role on a local model, so it spends nothing but keeps
# the RTX 4090 busy for ~25 minutes. On PRs only, as its own job so the
# checks above report first; a newer push to the same PR cancels the
# run it supersedes. Locally, run only what you're writing (-k) or
# diagnosing (--lf). Only after the checks pass: a PR that's red already
# doesn't spend the GPU.
# the RTX 4090 busy for ~25 minutes. On PRs only, and only after the
# checks pass: a PR that's red already doesn't spend the GPU. There is
# one runner and Gitea doesn't honour cancel-in-progress, so a run a
# newer push supersedes holds the runner until it's cancelled by hand.
# Locally, run only what you're writing (-k) or diagnosing (--lf).
if: github.event_name != 'push'
needs: checks
runs-on: ubuntu-latest
timeout-minutes: 90
concurrency:
group: e2e-${{ github.head_ref || github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
+63
View File
@@ -0,0 +1,63 @@
name: Release
# A published release builds the agent's image and pushes it to Gitea's
# registry (Development Plan M1.5, X4): gitea.dongar.net/dev/nisse-dev
# tagged with the version (the release tag without its v) and latest. Cut a
# release in Gitea (Releases → New release, tag vX.Y.Z); a tag pushed for any
# other reason builds nothing.
#
# The registry needs a personal access token with write:package
# (REGISTRY_TOKEN, an organization secret of `dev`, belonging to the
# registry-push bot), never the built-in job token: Gitea's registry
# resolves an Actions job token to an empty package scope (go-gitea/gitea
# #23642 and others). Packages belong to the repository's owner, which is
# why this repository is the `dev` organization's: the bot writes packages
# through the org's registry-push team. Never on pull_request: registry
# credentials stay off that trigger.
on:
release:
types: [published]
env:
REGISTRY: gitea.dongar.net
REGISTRY_USER: registry-push # the token's account
IMAGE: gitea.dongar.net/dev/nisse-dev
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive the image version from the release's tag
id: vars
env:
TAG: ${{ github.event.release.tag_name }}
run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea's container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push the agent
uses: docker/build-push-action@v5
with:
context: .
target: agent
push: true
build-args: |
VERSION=${{ steps.vars.outputs.version }}
labels: |
org.opencontainers.image.version=${{ steps.vars.outputs.version }}
org.opencontainers.image.revision=${{ github.sha }}
tags: |
${{ env.IMAGE }}:${{ steps.vars.outputs.version }}
${{ env.IMAGE }}:latest
+68
View File
@@ -0,0 +1,68 @@
# The nisse-dev agent as a container image (Development Plan M1.5, X4).
#
# Stateless: the state store and SSH known-hosts live in a volume
# (/var/lib/nisse-dev); configuration is mounted read-only
# (/etc/nisse-dev/nisse-dev.yaml); secrets come as environment variables
# (docker --env-file); SSH and deploy keys are mounted read-only wherever the
# configuration says. The agent runs no model-chosen command here (L13):
# those go to build VMs over SSH.
#
# docker build -t nisse-dev . the agent
# docker build --target test -t nisse-dev:test . plus the tests, for
# scripts/e2e.sh --image
FROM python:3.12-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /usr/local/bin/uv
ENV UV_PROJECT_ENVIRONMENT=/opt/nisse UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /src
COPY pyproject.toml uv.lock ./
COPY nisse-core nisse-core
COPY nisse-dev nisse-dev
COPY nisse-ops nisse-ops
# The agent and what it needs, installed (not editable): the source tree
# isn't part of the image.
RUN uv sync --frozen --no-dev --package nisse-dev --no-editable
FROM python:3.12-slim AS runtime
ARG VERSION=dev
LABEL org.opencontainers.image.title=nisse-dev org.opencontainers.image.version=$VERSION
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 10001 nisse \
&& mkdir -p /var/lib/nisse-dev /etc/nisse-dev \
&& chown nisse /var/lib/nisse-dev
COPY --from=build /opt/nisse /opt/nisse
ENV PATH=/opt/nisse/bin:$PATH \
NISSE_CONFIG=/etc/nisse-dev/nisse-dev.yaml \
NISSE_ENV=/run/nisse-dev/.env
VOLUME /var/lib/nisse-dev
USER nisse
ENTRYPOINT ["nisse-dev"]
# The image's own package plus the tests, so the end-to-end suite runs
# against what was built, not the source tree.
FROM runtime AS test
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends git openssh-server \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /run/sshd
COPY --from=build /usr/local/bin/uv /usr/local/bin/uv
WORKDIR /src
COPY pyproject.toml uv.lock ./
COPY nisse-core nisse-core
COPY nisse-dev nisse-dev
COPY nisse-ops nisse-ops
COPY tests tests
COPY scripts scripts
COPY conftest.py ./
ENV UV_PROJECT_ENVIRONMENT=/opt/nisse
# Test tools too, all installed (not editable), and `uv run` never re-syncs:
# the tests import the package as built, not the source tree beside them.
RUN uv sync --frozen --all-packages --no-editable --inexact
ENV UV_NO_SYNC=1
ENTRYPOINT []
# The default target: the agent itself.
FROM runtime AS agent
+26 -5
View File
@@ -76,8 +76,9 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
- **The end-to-end test runs on every PR** (M1.6): the real agent, every role on a
local model (so it costs nothing per call), a repository of the run's own and a
real build host, driven by a scripted operator. CI runs it as its own job on pull
requests, cancelling a run a newer push supersedes. Locally only the tests being
written (`-k`) or diagnosed (`--lf`); `scripts/e2e.sh` reports what a run spent. Manual
requests, after the checks pass. There is one runner, and a run a newer push
supersedes has to be cancelled by hand. Locally only the tests being written
(`-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
issues and either fixed before moving on or deliberately deferred.
- **The Quint models move with the loop:** `orchestrator.qnt` while `control.py`
@@ -240,8 +241,19 @@ deletes afterwards.
- Evaluation capture: delegations, `ask_expert` and the reader's verdicts.
- Deferred: the context-won't-fit trigger (L6); the action classifier (M3.2).
**M2.7 Conversation.** *Pre-approved.* Explanations, progress in prose, asking
mid-work (L5, #42, #77).
**M2.7 Conversation.** *Pre-approved.* **Done** (this PR). Explanations, progress
in prose, asking mid-work (L5, #42, #77).
- Questions never change state: a message ending in `?` to a waiting project item
is answered in a question run (`reply` and look-only tools), and the item goes
back to the very wait it was in, so a restatement up stays up and a go binds to
it.
- A restatement gives its reasons and the main alternative; a revision says what
changed (`restate(changed)`, sent back without it).
- Progress: the driver's and a helper's own words for a turn are posted, trimmed,
at most once a minute per item; `details` shows the last tool calls. A message
arriving mid-run is acknowledged at once.
- Deferred: streaming (#42); the question property in `specs/loop.qnt` (the walk
checks it against the code).
**M1.4 Projects** (D8). *Pre-approved.* **Done** (this PR).
- The project entry in configuration: repository, default branch, build VM, label
@@ -259,11 +271,20 @@ mid-work (L5, #42, #77).
- Deferred: config reload without a restart (with M2.5's entry step if wanted);
the trust dial's configuration (M3.3).
**M1.5 The agent as a container image.** *Pre-approved.*
**M1.5 The agent as a container image.** *Pre-approved.* **Built** (this PR);
accepted once a tag's image passes the e2e suite.
- A Dockerfile for `nisse-dev`, stateless with a volume for state; a Gitea Actions
workflow that builds it and pushes to Gitea's registry on each release tag (X4).
- `bringup.sh` runs the image instead of a venv.
- Acceptance: a tagged release produces an image; the end-to-end test runs from it.
- The image runs as its own user; state in `/var/lib/nisse-dev`, configuration
mounted at `/etc/nisse-dev/nisse-dev.yaml`, secrets as environment variables,
keys mounted read-only. Its `test` stage carries the tests over the installed
package, for `scripts/e2e.sh --image REF`. Images are built only on release:
`release.yml` pushes `gitea.dongar.net/dev/nisse-dev:<version>` and `:latest`
on a published release (`REGISTRY_TOKEN`, the `dev` organization's).
- The live bring-up's switch to the image waits for the next live test (it needs
its own test repository too); `bringup.sh plan` shows what it will run.
**M2.8 Switch over.** *Pre-approved, after acceptance.* `nisse-dev` runs the new
loop; `control.py`, its tests and `orchestrator.qnt` are removed.
+8
View File
@@ -173,6 +173,7 @@ class Delegation:
now: Callable[[], float] = time.monotonic,
cancel: Cancel | None = None,
reader: str = "",
on_turn: Callable[[str], None] | None = None,
) -> None:
self._client = client
self._store = store
@@ -180,6 +181,8 @@ class Delegation:
# Who acts on the handback, from its capability profile (L9): never
# a model's name.
self._reader = reader or "a materially weaker model"
# The delegate's own text for each turn, for progress (#42).
self._on_turn = on_turn
self._git = git_repo
self._model = model
self._backing_model = backing_model
@@ -400,6 +403,11 @@ class Delegation:
transcript.append(event)
if isinstance(event, ToolCall):
calls.append(event)
said = " ".join(
e.text.strip() for e in produced if isinstance(e, AssistantText)
).strip()
if said and self._on_turn is not None:
self._on_turn(said)
if not calls:
# A delegate that stops without handing back has not reported,
+31 -5
View File
@@ -39,6 +39,13 @@ class LLMError(RuntimeError):
RATE_LIMIT_STATUS = 429
RATE_LIMIT_RETRY_S = 20.0
# The proxy, or what's behind it, briefly gone: a 502/503/504 or a dropped
# connection. Outages delay, never fail (L5): waited out with backoff before
# the work is stopped. Not a 500 (it may be the request) and not a timeout
# (a hung model has had its request_timeout_s already).
OUTAGE_STATUSES = frozenset({502, 503, 504})
OUTAGE_BACKOFF_S = (5.0, 15.0, 45.0)
# Generous by default. Output is billed by what is produced, not by the
# ceiling, and a ceiling too low does not fail loudly — it truncates a tool
# call mid-argument and hands back something that parses. Measured: Opus
@@ -381,20 +388,39 @@ class AnthropicPassthroughClient:
def _post_with_rate_limit_retry(
http: httpx.Client, path: str, body: dict[str, Any], sleep: Any
) -> tuple[httpx.Response, float]:
"""The response, and how long was spent waiting out a rate limit."""
"""The response, and how long was spent waiting: out a rate limit (once)
or an outage (with backoff), not on the model."""
waited = 0.0
for attempt in (1, 2):
rate_limited = False
outages = iter(OUTAGE_BACKOFF_S)
while True:
try:
response = http.post(path, json=body)
except httpx.TimeoutException as exc:
raise UpstreamUnavailable(f"LLM request failed: {exc}") from exc
except httpx.HTTPError as exc:
# Could not reach the proxy at all: nothing about the request was
# wrong, so callers holding work in progress should keep it.
pause = next(outages, None)
if pause is None:
raise UpstreamUnavailable(f"LLM request failed: {exc}") from exc
if response.status_code != RATE_LIMIT_STATUS or attempt == 2:
return response, waited
sleep(pause)
waited += pause
continue
if response.status_code == RATE_LIMIT_STATUS and not rate_limited:
rate_limited = True
sleep(RATE_LIMIT_RETRY_S)
waited += RATE_LIMIT_RETRY_S
raise AssertionError("unreachable")
continue
if response.status_code in OUTAGE_STATUSES and not _looks_like_budget_refusal(
response.text
):
pause = next(outages, None)
if pause is not None:
sleep(pause)
waited += pause
continue
return response, waited
# Model families we have a native renderer adapter for. L9: delegations take
+150 -7
View File
@@ -29,6 +29,8 @@ from nisse_dev.canonical import (
DelegationStarted,
Fact,
PlanSubmitted,
ToolCall,
ToolResult,
UserMessage,
)
from nisse_dev.config import GENERAL
@@ -87,6 +89,9 @@ _NAMED = re.compile(r"(go|approve|abandon|stop) (?:([a-z0-9][a-z0-9-]*))?#(\d+)"
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"})
BUG_GRACE_S = 600.0
GENERAL_IDLE_S = 86400.0
PROGRESS_EVERY_S = 60.0 # progress in prose at a readable rate (L5, #42)
PROGRESS_CHARS = 400
DETAILS = 10 # tool calls shown by `details`
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).
@@ -102,9 +107,14 @@ The issue and the conversation about it are below.
Before any work, find out what you need, then use restate to put your
understanding to the operator: what you take the request to mean, how it fits
the project, and your first step. Wait for their go-ahead. If the request is
unclear, ask_human instead of guessing: a restatement asks no questions, so
the operator can agree to it as it stands.
the project, your first step, and why that way rather than the main
alternative. A later restatement says what changed. Wait for their go-ahead.
If the request is unclear, ask_human instead of guessing: a restatement asks
no questions, so the operator can agree to it as it stands. At any point,
asking is better than guessing.
Before each group of tool calls, say in a sentence what you're doing and why:
the operator sees it as your progress.
The project's documents are in its repository: read ARCHITECTURE.md and any
build plans under docs/plans/ before you restate.
@@ -119,7 +129,8 @@ the same milestone (don't start another), run the tests, and call
finish_milestone again to push the fix. When a review needs no change from
you, or you are only waiting for the operator to merge (you can't merge it
yourself), call wait_for_review. The next milestone starts only after the
previous PR has merged. When the last PR has merged, call close_item with
previous PR has merged. A milestone's summary says what was done, what was
verified, and what is left open. When the last PR has merged, call close_item with
what was done, what was verified, and what is left open. If a PR is closed
without merging, ask the operator what next.
@@ -133,6 +144,12 @@ Keep ARCHITECTURE.md current: a design change updates it in the same PR.
{workspace}"""
QUESTION = """
The operator has asked a question (the last message). Answer it with reply,
from what you know and what you can look at; don't change anything. The item
then goes back to waiting exactly as it was, so a restatement up stays up."""
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.
@@ -257,6 +274,9 @@ class LoopAgent:
# The project whose workspace was last worked on: switching to
# another parks it (L1). None after a boot: unknown, so all are.
self._last_project: str | None = None
# Per item: when progress was last posted (L5: a readable rate).
self._progressed: dict[str, float] = {}
self._active_name = "" # the running item, for "noted" on arrival
self._running = False
self._stop = threading.Event()
self._intake_thread: threading.Thread | None = None
@@ -743,6 +763,16 @@ class LoopAgent:
# Straight to the flag: the run is what it has to reach (L2).
self._cancel.set()
about = self._shown.get(thread) if thread else None
if self._running and command not in ("stop", "status"):
# L5: say what happens to a message that can't be read yet.
here = thread and thread == self._active_thread
self._transport.post(
"Noted; I'll read it when this turn ends."
if here
else f"Noted; I'm in the middle of {self._active_name}, and I'll "
"read it when that turn ends.",
thread=thread or msg.event_id or None,
)
self._worker.submit(
self._settled("your message", lambda: self.handle(msg, about))
)
@@ -814,6 +844,30 @@ class LoopAgent:
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
elif command == "done" and loop.new_project:
self._provisioned(item, loop)
elif command == "details":
self._transport.post(self._details(item), thread=loop.thread)
elif (
item.position == WAITING
and item.scope != GENERAL
and is_question(text)
and loop.wait in (GO_AHEAD, ANSWER, MERGE)
):
# L5: questions never change state. Answered, then back to this
# very wait: a restatement up stays up, a go still binds to it.
save(
self._store,
item.id,
READY,
replace(
loop,
question=True,
back_to=loop.wait,
back_note=loop.note,
wait="",
note="",
),
cause="the operator asked a question",
)
elif command == "stop":
# Stopping a run took effect on arrival; a stop is never an answer.
if not loop.note.startswith("stopped ("):
@@ -1332,6 +1386,9 @@ class LoopAgent:
caps=self._helpers.caps,
cancel=self._cancel,
reader=self._helpers.reader(roles),
on_turn=lambda said: self._progress(
f"{item.id}:helper", loop.thread, said, prefix="helper: "
),
)
git = milestones.git
before = git.head_sha() # where the milestone is, uncommitted work aside
@@ -1432,6 +1489,54 @@ class LoopAgent:
save(self._store, item.id, READY, loop_of(item), cause="a helper finished")
return True
def _changed_problem(self, item_id: str, args: dict[str, object]) -> str | None:
"""L5: a revision says what changed from the one before."""
item = self._store.work_item(item_id)
if item is None or loop_of(item).rev == 0:
return None
if str(args.get("changed", "")).strip():
return None
return (
f"not posted: this is a revision of rev-{loop_of(item).rev}, so say "
"in changed what's different and why"
)
def _progress(self, item_id: str, thread: str, said: str, prefix: str = "") -> None:
"""A turn's own words, posted at a readable rate (L5, #42): at most
one a minute per item, the latest when the minute is up."""
now = self._clock()
if now - self._progressed.get(item_id, float("-inf")) < PROGRESS_EVERY_S:
return
self._progressed[item_id] = now
text = said if len(said) <= PROGRESS_CHARS else said[:PROGRESS_CHARS] + " …"
self._transport.post(f"{prefix}{text}", thread=thread)
def _details(self, item: WorkItem) -> str:
"""L5: raw tool activity, on request: the item's last tool calls."""
events = self._store.transcript(item.id, "driver").read()
results = {
e.event.call_id: e.event for e in events if isinstance(e.event, ToolResult)
}
lines = []
for e in events:
if not isinstance(e.event, ToolCall):
continue
call, result = e.event, results.get(e.event.call_id)
target = call.arguments.get("path") or call.arguments.get("command") or ""
outcome = (
"no result yet"
if result is None
else ("ok" if result.success else "failed")
+ (f": {result.result.strip()[:120]}" if result.result.strip() else "")
)
who = "" if e.actor == "driver" else f"{e.actor}: "
lines.append(f"- {who}{call.tool}({str(target)[:80]}) → {outcome}")
if not lines:
return f"No tool calls yet on {self._label(item)}."
return f"The last tool calls on {self._label(item)}:\n" + "\n".join(
lines[-DETAILS:]
)
def _design_problem(self, item_id: str) -> str | None:
"""D1, D2: a `design` item agrees its design before any code. Until
a PR of it has merged, a milestone holds documents only."""
@@ -1544,6 +1649,8 @@ class LoopAgent:
save(self._store, item.id, RUNNING, loop, cause="driver run")
# D1: a `discuss` item talks before it restates.
hold = "discuss" in loop.labels and not loop.talked and not loop.started
if loop.question:
hold = True # nothing but answering (below)
tools = [
*([] if hold else [RESTATE]),
ASK_HUMAN,
@@ -1560,6 +1667,8 @@ class LoopAgent:
]
),
]
if loop.question:
tools = [REPLY, *_LOOK]
roles = self._roles_of(item)
workspace = project.workspace
system = SYSTEM.format(
@@ -1573,8 +1682,9 @@ class LoopAgent:
else ""
),
workspace=workspace.describe() if workspace else "",
)
) + (QUESTION if loop.question else "")
self._active_thread = loop.thread
self._active_name = self._label(item)
self._running = True
try:
outcome = self._driver.run(
@@ -1592,10 +1702,14 @@ class LoopAgent:
),
},
checks={
"restate": _restatement_problem,
"restate": lambda args: (
_restatement_problem(args)
or self._changed_problem(item.id, args)
),
"finish_milestone": lambda args: self._design_problem(item.id),
},
model=roles.resolve("driver"),
on_turn=lambda said: self._progress(item.id, loop.thread, said),
)
finally:
self._running = False
@@ -1607,6 +1721,31 @@ class LoopAgent:
assert item is not None
loop = loop_of(item)
args = outcome.arguments or {}
if loop.question:
# Back to the wait the question interrupted, as it was (L5).
if outcome.kind == "wait" and outcome.tool == "reply":
self._transport.post(
str(args.get("text", "")).strip(), thread=loop.thread
)
else:
self._transport.post(
f"I couldn't answer that ({outcome.note or outcome.kind}).",
thread=loop.thread,
)
self._wait(
item,
replace(
loop,
wait=loop.back_to,
note=loop.back_note,
question=False,
back_to="",
back_note="",
),
None,
)
self._store.set_active(None, cause="question answered")
return
if outcome.kind == "wait" and outcome.tool == "restate":
text = str(args.get("text", "")).strip()
rev = loop.rev + 1
@@ -1622,7 +1761,11 @@ class LoopAgent:
f"It's a bug, so I'll go ahead in {round(self._grace / 60)} min "
"unless you reply; say go to start now, or reply with changes."
)
self._transport.post(f"rev-{rev}: {text}\n\n{after}", thread=loop.thread)
changed = str(args.get("changed", "")).strip()
since = f"\n\nChanged from rev-{rev - 1}: {changed}" if rev > 1 else ""
self._transport.post(
f"rev-{rev}: {text}{since}\n\n{after}", thread=loop.thread
)
self._wait(
item,
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
+8 -1
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from typing import Any, Literal
from nisse_dev.cancel import Cancel, skip_calls
from nisse_dev.canonical import ToolCall, ToolResult
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult
from nisse_dev.llm import LLMClient, LLMError
from nisse_dev.loop.tools import WAITING, names
from nisse_dev.state import Transcript
@@ -63,6 +63,7 @@ class Driver:
| None = None,
checks: Mapping[str, Callable[[dict[str, Any]], str | None]] | None = None,
model: str | None = None,
on_turn: Callable[[str], None] | None = None,
) -> RunOutcome:
"""``handlers`` answer control tools that don't end the run (e.g.
start_milestone): each returns a result and whether it succeeded.
@@ -89,6 +90,12 @@ class Driver:
transcript.append(event)
if isinstance(event, ToolCall):
calls.append(event)
# The model's own words for the turn: its progress, in prose (#42).
said = " ".join(
e.text.strip() for e in produced if isinstance(e, AssistantText)
).strip()
if said and calls and on_turn is not None:
on_turn(said)
if not calls:
if nudged:
return RunOutcome("failed", note="kept replying in prose")
+5
View File
@@ -58,6 +58,11 @@ class Loop:
design_merged: bool = False # a `design` item's design PR has merged (D1, D2)
helps: int = 0 # delegations so far, for their branch names (L9)
helped_at: int = 0 # the milestone a helper was last called in for, unasked
# A question from the operator is answered without changing the item
# (L5): the wait it interrupted, to go back to.
question: bool = False
back_to: str = ""
back_note: str = ""
# 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
# little of what they said, for quoting.
+5 -2
View File
@@ -25,9 +25,12 @@ def _tool(tool: str, description: str, /, **params: str) -> dict[str, Any]:
RESTATE = _tool(
"restate",
"Put your understanding of the request to the operator, in their item's "
"thread: what you take it to mean, how it fits the project, and your "
"first step. Ends your turn; work starts when they agree.",
"thread: what you take it to mean, how it fits the project, your first "
"step, and why that way (and the main alternative you considered). Ends "
"your turn; work starts when they agree.",
text="Your restatement, in plain language.",
changed="What changed from your previous restatement, and why; leave it "
"empty for the first.",
)
ASK_HUMAN = _tool(
"ask_human",
+3
View File
@@ -143,6 +143,9 @@ class FakeTransport:
def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
"""Assemble the real graph with only the network boundaries faked."""
config = config_for(tmp_path)
# The proxy here doesn't exist: a call to it fails at once rather than
# waiting out an outage's backoff (L5).
monkeypatch.setattr("nisse_dev.llm.OUTAGE_BACKOFF_S", ())
monkeypatch.setattr(
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
+79
View File
@@ -14,6 +14,7 @@ from nisse_dev.canonical import (
from nisse_dev.envfile import load_env_file
from nisse_dev.llm import (
DEFAULT_MAX_OUTPUT_TOKENS,
OUTAGE_BACKOFF_S,
RATE_LIMIT_RETRY_S,
AnthropicPassthroughClient,
BudgetExhausted,
@@ -432,3 +433,81 @@ def test_the_answering_model_is_recorded() -> None:
)
client.complete(EVENTS, tools=None, model="local-fast")
assert client.last_model == "gpt-test-1"
# --- outages delay, never fail (L5) --------------------------------------------
def test_a_gateway_error_is_waited_out_with_backoff() -> None:
responses = [
httpx.Response(502, text="bad gateway"),
httpx.Response(503, text="unavailable"),
completion({"content": "back"}),
]
waited: list[float] = []
client = make_client(lambda request: responses.pop(0), sleep=waited.append)
[event] = client.complete(EVENTS, tools=None, model="local/qwen")
assert isinstance(event, AssistantText) and event.text == "back"
assert waited == list(OUTAGE_BACKOFF_S[:2])
# Waiting isn't the model's time.
assert client.last_rate_limit_wait_s == sum(OUTAGE_BACKOFF_S[:2])
def test_an_outage_that_outlasts_the_backoff_is_surfaced() -> None:
attempts: list[int] = []
waited: list[float] = []
def handler(request: httpx.Request) -> httpx.Response:
attempts.append(1)
return httpx.Response(502, text="bad gateway")
client = make_client(handler, sleep=waited.append)
with pytest.raises(UpstreamUnavailable, match="502"):
client.complete(EVENTS, tools=None, model="local/qwen")
assert len(attempts) == len(OUTAGE_BACKOFF_S) + 1
assert waited == list(OUTAGE_BACKOFF_S)
def test_a_dropped_connection_is_retried() -> None:
calls: list[int] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(1)
if len(calls) == 1:
raise httpx.ConnectError("refused")
return completion({"content": "ok"})
[event] = make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
assert isinstance(event, AssistantText) and len(calls) == 2
def test_a_500_or_a_timeout_is_not_retried() -> None:
# A 500 may be the request's fault; a timeout already had its full
# request_timeout_s.
for failure in (
lambda request: httpx.Response(500, text="boom"),
lambda request: (_ for _ in ()).throw(httpx.ReadTimeout("slow")),
):
calls: list[int] = []
def handler(
request: httpx.Request, failure=failure, calls=calls
) -> httpx.Response:
calls.append(1)
return failure(request)
with pytest.raises(LLMError):
make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
assert len(calls) == 1
def test_a_budget_refusal_is_not_waited_on() -> None:
calls: list[int] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(1)
return httpx.Response(503, text="Budget has been exceeded! Current cost: 5.1")
with pytest.raises(BudgetExhausted):
make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
assert len(calls) == 1
+3 -1
View File
@@ -214,7 +214,9 @@ def test_go_starts_the_work_and_close_item_closes_the_issue(h: Harness) -> None:
def test_a_go_for_an_earlier_revision_is_not_applied(h: Harness) -> None:
h.model.turns.append([call("restate", text="revised: in a new module")])
h.model.turns.append(
[call("restate", text="revised: in a new module", changed="a new module")]
)
h.agent.intake()
h.say("put it in its own module") # changes, so the driver restates rev-2
assert loop_of(h.item()).rev == 2
+23
View File
@@ -301,3 +301,26 @@ def test_a_delegation_cut_off_by_a_crash_is_discarded_at_boot(
]
assert any("cut off by a restart and discarded" in f for f in facts)
assert git
def test_a_helpers_progress_is_posted(world: dict[str, Any]) -> None:
helper = Spy(
[
AssistantText(text="Writing helped.py with the constant.", model="hlp"),
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("ask_human", question="done?"),
],
)
agent, transport = loop_agent(world, model, helpers=helpers(helper))
agent.intake()
say(transport, world, "go")
said = [m.text for m in transport.outbound]
assert "helper: Writing helped.py with the constant." in said
+11 -4
View File
@@ -118,7 +118,7 @@ def test_a_plain_yes_is_a_go_ahead_said_so_first(tmp_path: Path) -> None:
def test_anything_else_goes_to_the_driver(tmp_path: Path) -> None:
h = Harness(
tmp_path,
ScriptedModel(RESTATED, [call("restate", text="v2")]),
ScriptedModel(RESTATED, [call("restate", text="v2", changed="as you asked")]),
FakeGitea(issue()),
reader=Reader(),
)
@@ -132,7 +132,7 @@ def test_a_question_never_reaches_the_reader(tmp_path: Path) -> None:
reader = Reader("sounds good?")
h = Harness(
tmp_path,
ScriptedModel(RESTATED, [call("restate", text="v2")]),
ScriptedModel(RESTATED, [call("restate", text="v2", changed="as you asked")]),
FakeGitea(issue()),
reader=reader,
)
@@ -145,7 +145,9 @@ def test_a_plain_yes_to_an_older_revision_is_not_a_go(tmp_path: Path) -> None:
reader = Reader("yes")
h = Harness(
tmp_path,
ScriptedModel(RESTATED, [call("restate", text="v2")], STARTED),
ScriptedModel(
RESTATED, [call("restate", text="v2", changed="as you asked")], STARTED
),
FakeGitea(issue()),
reader=reader,
)
@@ -224,7 +226,12 @@ def test_a_reply_stops_the_bugs_countdown(tmp_path: Path) -> None:
def test_a_new_revision_of_a_bug_gets_its_own_countdown(tmp_path: Path) -> None:
h, clock = bug(tmp_path, RESTATED, [call("restate", text="v2")], STARTED)
h, clock = bug(
tmp_path,
RESTATED,
[call("restate", text="v2", changed="as you asked")],
STARTED,
)
h.agent.intake()
clock.now += 300
h.say("use the other file")
+1 -1
View File
@@ -95,7 +95,7 @@ def test_the_next_milestone_waits_for_the_merge(world: dict[str, Any]) -> None:
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
say(transport, world, "how's it going?")
say(transport, world, "carry on")
assert "hasn't merged yet" in result_of(world, "start_milestone")
assert (item(world).position, loop_of(item(world)).wait) == (WAITING, MERGE)
assert len(forge.prs) == 1
+26 -2
View File
@@ -76,6 +76,8 @@ class PolicyModel:
return [call("ask_human", question=f"question {self.n}?")]
if roll < 0.12:
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})]
if "reply" in offered and "propose_work" not in offered:
return [call("reply", text=f"because {self.n}")] # a question run
if "reply" in offered: # a conversation: talk, or propose work
if self.rng.random() < 0.1:
return [
@@ -99,7 +101,7 @@ class PolicyModel:
if "restate" not in offered and "finish_milestone" not in offered:
return [call("ask_human", question=f"talk first {self.n}?")] # discuss
if "finish_milestone" not in offered:
return [call("restate", text=f"restatement {self.n}")]
return [call("restate", text=f"restatement {self.n}", changed=f"c{self.n}")]
if "delegate" in offered and self.rng.random() < 0.1:
return [call("delegate", task=f"help {self.n}")]
if "wait_for_review" in offered and self.rng.random() < 0.2:
@@ -248,7 +250,7 @@ class Walk:
forge: Forge = self.worlds[project]["forge"]
if item and loop:
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
weights.update({"plain-yes": 2, "wait": 2})
weights.update({"plain-yes": 2, "wait": 2, "question": 2})
if loop.new_project:
weights.update({"done": 2})
if item.scope != "general": # conversations aren't named by number
@@ -264,6 +266,28 @@ class Walk:
elif action == "done" and loop:
# The operator says a new project is ready; no entry was added.
self.transport.receive(DON, "done", thread=loop.thread)
elif action == "question" and loop:
# L5: a question never changes the item it's asked about.
before = (item.position, loop.wait, loop.rev, loop.pr, loop.branch)
self.transport.receive(DON, f"why {len(self.steps)}?", thread=loop.thread)
after_item = self.world["store"].work_item(item.id)
after = loop_of(after_item)
if (
item.scope != "general"
and item.position == WAITING
and loop.wait in (GO_AHEAD, ANSWER, MERGE)
):
now = (
after_item.position,
after.wait,
after.rev,
after.pr,
after.branch,
)
assert now == before, (
f"questions_change_nothing: {before} -> {now}: "
f"seed {self.seed}, steps {self.steps}"
)
elif action == "chat":
# A top-level message: a conversation of its own (L1, L5).
self.transport.receive(DON, f"thinking aloud {len(self.steps)}")
+163
View File
@@ -0,0 +1,163 @@
"""Conversation on the new loop (M2.7, L5, #42, #77): questions answered
without changing the item, revisions that say what changed, progress in
prose at a readable rate, raw tool activity on request, and a note for a
message that can't be read yet."""
from pathlib import Path
from typing import Any
from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
from nisse_dev.canonical import AssistantText
from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of
class Clock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def harness(tmp_path: Path, *turns: list[Any], **kw: Any) -> Harness:
return Harness(tmp_path, ScriptedModel(*turns), FakeGitea(issue()), **kw)
# --- questions never change state (#77) -------------------------------------
def test_a_question_about_a_restatement_leaves_it_up(tmp_path: Path) -> None:
h = harness(
tmp_path,
RESTATED,
[call("reply", text="Because text.py already holds the string helpers.")],
[call("ask_human", question="started; anything else?")],
)
h.agent.intake()
h.say("Why text.py?")
loop = loop_of(h.item())
assert (h.item().position, loop.wait, loop.rev) == (WAITING, GO_AHEAD, 1)
assert not loop.question and not loop.started
assert h.posted()[-1] == "Because text.py already holds the string helpers."
h.say("go") # still bound to rev-1
assert loop_of(h.item()).started
def test_a_question_back_keeps_the_agents_own_question_open(tmp_path: Path) -> None:
h = harness(
tmp_path,
[call("ask_human", question="One function or two?")],
[call("reply", text="One handles words, two would split lines too.")],
)
h.agent.intake()
h.say("What would the second one do?")
loop = loop_of(h.item())
assert (loop.wait, loop.note) == (ANSWER, "One function or two?")
def test_a_question_that_isnt_answered_still_goes_back(tmp_path: Path) -> None:
h = harness(
tmp_path,
RESTATED,
[call("restate", text="sneaky v2", changed="x")], # not offered
[AssistantText(text="hmm", model="m")],
[AssistantText(text="hmm", model="m")], # prose twice: the run fails
)
h.agent.intake()
h.say("why?")
loop = loop_of(h.item())
# restate isn't offered in a question run: no new revision.
assert (loop.wait, loop.rev) == (GO_AHEAD, 1)
assert h.posted()[-1].startswith("I couldn't answer that")
# --- explanations ------------------------------------------------------------
def test_a_revision_says_what_changed(tmp_path: Path) -> None:
h = harness(
tmp_path,
RESTATED,
[call("restate", text="count_words in words.py", changed="")],
[
call(
"restate",
text="count_words in words.py",
changed="a module of its own, as you asked",
)
],
)
h.agent.intake()
h.say("put it in its own module")
loop = loop_of(h.item())
assert loop.rev == 2
assert "Changed from rev-1: a module of its own, as you asked" in h.posted()[-1]
# --- progress and details (#42) ----------------------------------------------
def test_progress_is_posted_at_a_readable_rate(tmp_path: Path) -> None:
clock = Clock()
class Ticking(ScriptedModel):
def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def]
clock.now += 30 # each turn takes a while
return super().complete(events, tools, model, system)
h = Harness(
tmp_path,
Ticking(
[
AssistantText(text="Looking at text.py first.", model="m"),
call("list_dir", path="."),
],
[
AssistantText(text="Reading the tests too.", model="m"),
call("list_dir", path="tests"),
],
[
AssistantText(text="x" * 1000, model="m"),
call("list_dir", path="."),
],
[call("restate", text="I'll add count_words.", changed="")],
),
FakeGitea(issue()),
clock=clock,
)
h.agent.intake()
said = h.posted()
assert "Looking at text.py first." in said
assert "Reading the tests too." not in said # 30 s later: folded away
long = [p for p in said if p.startswith("xxx")]
assert long and len(long[0]) < 420 and long[0].endswith(" …") # 60 s on
def test_details_shows_the_last_tool_calls(tmp_path: Path) -> None:
h = harness(
tmp_path,
[
call("list_dir", path="src"),
call("read_file", path="text.py"),
call("restate", text="I'll add count_words.", changed=""),
],
)
h.agent.intake()
h.say("details")
shown = h.posted()[-1]
assert shown.startswith("The last tool calls on #14:")
assert "list_dir(src)" in shown and "read_file(text.py)" in shown
assert loop_of(h.item()).wait == GO_AHEAD # nothing changed
def test_a_message_mid_run_is_acknowledged(tmp_path: Path) -> None:
h = harness(tmp_path, RESTATED)
h.agent.intake()
h.agent._running = True # as if a driver run were under way
h.agent._active_thread = "elsewhere"
h.agent._active_name = "#15"
h.transport.receive(DON, "one more thing", thread=h.thread())
assert "Noted; I'm in the middle of #15" in h.posted()[-1]
h.transport.receive(DON, "and another", thread="elsewhere")
assert h.posted()[-1] == "Noted; I'll read it when this turn ends."
+54 -22
View File
@@ -1,13 +1,19 @@
#!/usr/bin/env bash
# Bring-up runs of nisse-dev on this development VM (Development Plan M0.2).
#
# The agent runs as root, as it will on its own VM in production (D7): it may
# install what it needs. It touches only test resources (the sandbox repo, the
# test Matrix room, a budget-capped LiteLLM key), from the run's config. It
# runs as a transient systemd unit, so it outlives the shell that started it.
# The agent runs as its container image (M1.5), built from the code under
# test, as a transient systemd unit, so it outlives the shell that started
# it. It touches only test resources (an operator-made test repository, the
# test Matrix room, a budget-capped LiteLLM key), from the run's config.
#
# scripts/bringup.sh start [REF] set the last run aside, export REF (default
# HEAD) and start the agent
# $E2E is mounted at the same path inside the container, so the config's
# paths (state_db, keys) mean the same there; for that it runs as root here,
# since the keys are root's. In production the image runs as its own user
# with a state volume (Dockerfile).
#
# scripts/bringup.sh start [REF] set the last run aside, build REF (default
# HEAD) into an image and start the agent
# scripts/bringup.sh plan [REF] print what start would build and run
# scripts/bringup.sh stop stop it cleanly (SIGTERM)
# scripts/bringup.sh status the unit, and the agent's state
# scripts/bringup.sh logs follow the agent's log
@@ -16,7 +22,7 @@
#
# Layout, under $E2E (default /root/nisse-e2e):
# nisse-dev.yaml, .env the run config and its secrets (made by hand once)
# code/ the exported code under test, with its own .venv
# code/ the exported code under test, built into the image
# state.db the agent's state (its workspace is on the build VM)
# ssh/ the agent's key for the build VM, and push keys
# run-info, collected/ what was run, and the output of `collect`
@@ -27,11 +33,28 @@ E2E=${NISSE_E2E:-/root/nisse-e2e}
SRC=${NISSE_SRC:-/root/nisse} # repository to export code from
CODE=$E2E/code
UNIT=nisse-bringup
UV=${UV:-$(command -v uv || echo /snap/bin/uv)}
IMAGE=nisse-dev:bringup
die() { echo "bringup: $*" >&2; exit 1; }
running() { systemctl is-active --quiet "$UNIT"; }
# The container the unit runs: host networking (Matrix, Gitea, LiteLLM as
# here), $E2E at its own path, the config and secrets from it.
docker_run() {
echo docker run --rm --name "$UNIT" --network host --user 0 \
-v "$E2E:$E2E" \
-e NISSE_CONFIG="$E2E/nisse-dev.yaml" -e NISSE_ENV="$E2E/.env" \
"$IMAGE"
}
cmd_plan() {
local ref=${1:-HEAD} sha
sha=$(git -C "$SRC" rev-parse --verify "$ref^{commit}") || die "unknown ref $ref"
echo "git -C $SRC archive $sha | tar -x -C $CODE"
echo "docker build -t $IMAGE $CODE"
echo "systemd-run --unit=$UNIT ... $(docker_run)"
}
cmd_start() {
local ref=${1:-HEAD} sha
[ -f "$E2E/nisse-dev.yaml" ] && [ -f "$E2E/.env" ] || die "no config in $E2E"
@@ -58,16 +81,16 @@ cmd_start() {
rm -rf "$CODE"
mkdir -p "$CODE"
git -C "$SRC" archive "$sha" | tar -x -C "$CODE"
(cd "$CODE" && "$UV" sync --quiet --all-packages)
docker build --quiet -t "$IMAGE" "$CODE" > /dev/null
printf 'ref: %s\ncommit: %s\nstarted: %s\n' "$ref" "$sha" "$(date -Is)" > "$E2E/run-info"
systemctl reset-failed "$UNIT" 2>/dev/null || true
# SIGTERM reaches the agent through docker run's signal proxy; it shuts
# down cleanly (main.py).
# shellcheck disable=SC2046
systemd-run --quiet --unit="$UNIT" --collect \
--property=WorkingDirectory="$CODE" \
--property=KillSignal=SIGTERM --property=TimeoutStopSec=120 \
--setenv=NISSE_CONFIG="$E2E/nisse-dev.yaml" \
--setenv=NISSE_ENV="$E2E/.env" \
"$CODE/.venv/bin/nisse-dev"
$(docker_run)
echo "started $UNIT at ${sha:0:8} ($ref)"
}
@@ -78,15 +101,17 @@ remote_aside() {
where=$(python3 - "$E2E/nisse-dev.yaml" <<'PY'
import sys, yaml
c = yaml.safe_load(open(sys.argv[1]))
b = c["build_host"]
print(b.get("user", "root"), b["address"], b.get("port", 22), b["key"], c["workspace"])
for p in c["projects"].values(): # each project's workspace, on its build VM (D8)
b = p["build_host"]
print(b.get("user", "root"), b["address"], b.get("port", 22), b["key"], p["workspace"])
PY
) || die "cannot read build_host and workspace from the config"
read -r user address port key workspace <<< "$where"
ssh -i "$key" -p "$port" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
) || die "cannot read the projects' build VMs and workspaces from the config"
while read -r user address port key workspace; do
ssh -n -i "$key" -p "$port" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="$E2E/ssh/known_hosts" "$user@$address" \
"if [ -e '$workspace' ]; then mv '$workspace' '$workspace.$stamp' && echo 'build VM: set aside $workspace.$stamp'; fi" \
|| die "cannot reach the build VM $address"
done <<< "$where"
}
cmd_stop() {
@@ -98,28 +123,35 @@ cmd_stop() {
cmd_status() {
if running; then echo "unit: running"; else echo "unit: not running"; fi
[ -f "$E2E/run-info" ] && sed 's/^/ /' "$E2E/run-info"
if [ -f "$E2E/state.db" ] && [ -x "$CODE/.venv/bin/python" ]; then
"$CODE/.venv/bin/python" -m nisse_dev.dump "$E2E/state.db" | head -n 3
if [ -f "$E2E/state.db" ]; then
dump "$E2E/state.db" | head -n 3
fi
}
cmd_logs() { journalctl -u "$UNIT" -f -o cat; }
# The state store, read with the image's own code.
dump() {
docker run --rm --user 0 -v "$E2E:$E2E" --entrypoint python "$IMAGE" \
-m nisse_dev.dump "$@"
}
cmd_collect() {
[ -f "$E2E/run-info" ] || die "no run to collect"
local out=$E2E/collected since
since=$(sed -n 's/^started: //p' "$E2E/run-info")
mkdir -p "$out"
journalctl -u "$UNIT" --since "$since" -o cat > "$out/agent.log"
"$CODE/.venv/bin/python" -m nisse_dev.dump --full "$E2E/state.db" > "$out/transcript.txt"
dump --full "$E2E/state.db" > "$out/transcript.txt"
echo "collected into $out"
}
case "${1:-}" in
start) shift; cmd_start "$@" ;;
plan) shift; cmd_plan "$@" ;;
stop) cmd_stop ;;
status) cmd_status ;;
logs) cmd_logs ;;
collect) cmd_collect ;;
*) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
*) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac
+14
View File
@@ -6,6 +6,8 @@
#
# scripts/e2e.sh on the bring-up build VM (configured below)
# scripts/e2e.sh --local on a throwaway local sshd instead
# scripts/e2e.sh --image REF inside an image's `test` stage (Dockerfile),
# on its own sshd: checks a release as built
#
# Uses tests/integration/nisse-test.yaml and its secrets (.env at the repo
# root), like the integration tests. Every role runs on a local model
@@ -14,6 +16,18 @@
set -euo pipefail
cd "$(dirname "$0")/.."
if [ "${1:-}" = "--image" ]; then
image=${2:?--image needs an image reference}
shift 2
runs=${NISSE_E2E_RUNS:-/root/nisse-e2e/test-runs}
mkdir -p "$runs"
secrets=()
[ -f .env ] && secrets=(--env-file .env)
# Host networking: the suite reaches Matrix, Gitea and LiteLLM as here.
exec docker run --rm --network host "${secrets[@]}" \
-e NISSE_E2E_RUNS=/runs -v "$runs:/runs" \
"$image" scripts/e2e.sh --local "$@"
fi
if [ "${1:-}" = "--local" ]; then
shift
else
+32
View File
@@ -634,3 +634,35 @@ def test_a_conversation_becomes_a_new_project(
]
assert "ARCHITECTURE.md" in files, files
assert any(f.startswith("docs/plans/") for f in files), files
def test_a_question_leaves_the_restatement_up(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.7: a question while a restatement is up is answered, and changes
nothing: the same revision stays up, and a go binds to it."""
agent, transport, run = running
operator = Operator(agent, transport)
repo = agent.config.gitea_repo
as_operator = operator_gitea(agent.config.gitea_url)
issues: list[int] = []
try:
issues.append(as_operator.create_issue(repo, f"vowels_{run}", task(run)))
as_operator._request(
"PATCH",
f"/repos/{repo}/issues/{issues[0]}",
json={"assignees": [agent.account]},
)
operator.tick()
item = operator.drive_until(GO_AHEAD, issues[0])
rev = loop_of(item).rev
said = len(transport.outbound)
operator.say("Why put it in that module rather than a new one?", issues[0])
answer = [m.text for m in transport.outbound[said:]]
assert answer and not answer[-1].startswith("rev-"), answer
loop = loop_of(operator.item(issues[0]))
assert (loop.wait, loop.rev) == (GO_AHEAD, rev), loop
operator.say("go", issues[0])
assert loop_of(operator.item(issues[0])).started
finally:
operator.tidy(issues, [])