Compare commits
8
Commits
v0.1.0
...
613a2a7f72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613a2a7f72 | ||
|
|
659915d309 | ||
|
|
572bd9f8eb | ||
|
|
d7e4fd0938 | ||
|
|
13efa54f16 | ||
|
|
28092194fe | ||
|
|
ff5a7a644f | ||
|
|
e35aea5045 |
No files matched your search
+42
-20
@@ -1,4 +1,10 @@
|
||||
name: CI
|
||||
# Three levels of testing:
|
||||
# - every PR: lint, unit tests (16 property walks), integration tests, and
|
||||
# the smoke end-to-end tests (`smoke` marker);
|
||||
# - a PR labelled e2e:full, a push to main and a manual run: all 48 walks
|
||||
# and the whole end-to-end suite;
|
||||
# - a published release: the full set again before its image (release.yml).
|
||||
on:
|
||||
# Only main on push. A PR branch is already covered by the pull_request
|
||||
# event, and running both fired two identical suites concurrently — which
|
||||
@@ -7,11 +13,18 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# labeled: adding e2e:full runs the full set; any other label, nothing.
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
# Manual re-run, e.g. after rotating a credential or raising a budget cap.
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Full on e2e:full, and outside PRs.
|
||||
FULL: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e:full') }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'e2e:full'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -26,12 +39,10 @@ jobs:
|
||||
|
||||
- name: Install sshd
|
||||
# The tests reach "the build VM" over real SSH, through a throwaway
|
||||
# sshd the test fixture starts (nisse_dev/testing.py).
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq openssh-server openssh-client > /dev/null
|
||||
$SUDO mkdir -p /run/sshd
|
||||
# sshd the test fixture starts (nisse_dev/testing.py). Skipped when the image has it; otherwise from
|
||||
# a kernel.org mirror: archive.ubuntu.com has been slow enough to add
|
||||
# minutes to every job.
|
||||
run: scripts/ci-sshd.sh
|
||||
|
||||
- name: Lint
|
||||
run: uv run ruff check .
|
||||
@@ -40,7 +51,12 @@ jobs:
|
||||
run: uv run ruff format --check .
|
||||
|
||||
- name: Unit tests
|
||||
run: uv run pytest -m "not integration and not e2e"
|
||||
# Across the runner's cores (pytest-xdist). On a PR, 16 property
|
||||
# walks from a seed that moves with the run number; all 48 otherwise.
|
||||
env:
|
||||
NISSE_WALK_SEEDS: ${{ env.FULL == 'true' && '48' || '16' }}
|
||||
NISSE_WALK_FIRST: ${{ env.FULL == 'true' && '0' || github.run_number }}
|
||||
run: uv run pytest -n auto -m "not integration and not e2e"
|
||||
|
||||
- name: Integration tests
|
||||
# Everything but the secrets is in tests/integration/nisse-test.yaml,
|
||||
@@ -58,12 +74,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, 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'
|
||||
# the RTX 4090 busy: ~10 minutes for the smoke tests, ~25+ for all. On
|
||||
# PRs and manual runs, 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'
|
||||
&& (github.event.action != 'labeled' || github.event.label.name == 'e2e:full')
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
@@ -79,12 +98,10 @@ jobs:
|
||||
run: uv sync --all-packages
|
||||
|
||||
- name: Install sshd
|
||||
# The build VM is a throwaway sshd here (scripts/e2e.sh --local).
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq openssh-server openssh-client > /dev/null
|
||||
$SUDO mkdir -p /run/sshd
|
||||
# The build VM is a throwaway sshd here (scripts/e2e.sh --local). Skipped when the image has it; otherwise from
|
||||
# a kernel.org mirror: archive.ubuntu.com has been slow enough to add
|
||||
# minutes to every job.
|
||||
run: scripts/ci-sshd.sh
|
||||
|
||||
- name: End-to-end tests
|
||||
env:
|
||||
@@ -93,4 +110,9 @@ jobs:
|
||||
NISSE_GITEA_TOKEN: ${{ secrets.NISSE_GITEA_TOKEN }}
|
||||
NISSE_OPERATOR_TOKEN: ${{ secrets.NISSE_OPERATOR_TOKEN }}
|
||||
NISSE_E2E_RUNS: /tmp/nisse-e2e-runs
|
||||
run: scripts/e2e.sh --local -v
|
||||
run: |
|
||||
if [ "$FULL" = true ]; then
|
||||
scripts/e2e.sh --local -v
|
||||
else
|
||||
scripts/e2e.sh --local -v -m "e2e and smoke"
|
||||
fi
|
||||
@@ -3,7 +3,8 @@ name: Release
|
||||
# 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.
|
||||
# other reason builds nothing. The full test set runs first: no image without
|
||||
# it passing.
|
||||
#
|
||||
# The registry needs a personal access token with write:package
|
||||
# (REGISTRY_TOKEN, an organization secret of `dev`, belonging to the
|
||||
@@ -23,7 +24,46 @@ env:
|
||||
IMAGE: gitea.dongar.net/dev/nisse-dev
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
# Everything, before an image exists: lint, unit tests with all 48
|
||||
# property walks, integration tests and the whole end-to-end suite.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
NISSE_LLM_TOKEN: ${{ secrets.NISSE_LLM_TOKEN }}
|
||||
NISSE_MATRIX_TOKEN: ${{ secrets.NISSE_MATRIX_TOKEN }}
|
||||
NISSE_GITEA_TOKEN: ${{ secrets.NISSE_GITEA_TOKEN }}
|
||||
NISSE_OPERATOR_TOKEN: ${{ secrets.NISSE_OPERATOR_TOKEN }}
|
||||
NISSE_E2E_RUNS: /tmp/nisse-e2e-runs
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Sync workspace
|
||||
run: uv sync --all-packages
|
||||
|
||||
- name: Install sshd
|
||||
run: scripts/ci-sshd.sh
|
||||
|
||||
- name: Lint
|
||||
run: uv run ruff check . && uv run ruff format --check .
|
||||
|
||||
- name: Unit tests
|
||||
run: uv run pytest -n auto -m "not integration and not e2e"
|
||||
|
||||
- name: Integration tests
|
||||
run: uv run pytest -m "integration and not e2e" -rs
|
||||
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh --local -v
|
||||
|
||||
build-and-push:
|
||||
needs: tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+13
-8
@@ -10,6 +10,12 @@
|
||||
# docker build -t nisse-dev . the agent
|
||||
# docker build --target test -t nisse-dev:test . plus the tests, for
|
||||
# scripts/e2e.sh --image
|
||||
# docker build --target test --build-arg TEST_BASE=<published image> \
|
||||
# -t nisse-dev:test . the tests over a release as
|
||||
# published (M1.5 acceptance)
|
||||
|
||||
# What the test stage stacks on: this build's agent, or a published image.
|
||||
ARG TEST_BASE=runtime
|
||||
|
||||
FROM python:3.12-slim AS build
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /usr/local/bin/uv
|
||||
@@ -40,9 +46,10 @@ 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
|
||||
# The tests over an image's own package (this build's, or a published one,
|
||||
# TEST_BASE): only the test tools are added, never the agent reinstalled, so
|
||||
# the end-to-end suite runs against what was built, not the source beside it.
|
||||
FROM ${TEST_BASE} AS test
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git openssh-server \
|
||||
@@ -57,11 +64,9 @@ 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
|
||||
ENV UV_PROJECT_ENVIRONMENT=/opt/nisse UV_NO_SYNC=1
|
||||
RUN uv export --frozen --only-group dev --no-hashes --no-emit-project > /tmp/dev.txt \
|
||||
&& uv pip install --quiet --python /opt/nisse/bin/python -r /tmp/dev.txt
|
||||
ENTRYPOINT []
|
||||
|
||||
# The default target: the agent itself.
|
||||
|
||||
@@ -73,10 +73,14 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
|
||||
`CLAUDE.md`. A milestone marked **needs discussion** starts with a design
|
||||
discussion first.
|
||||
- **Every change comes with tests,** through `scripts/check.sh`.
|
||||
- **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, after the checks pass. There is one runner, and a run a newer push
|
||||
- **Testing has three levels** (M1.6): every PR runs the checks (unit tests across
|
||||
the runner's cores, 16 property walks from a moving seed, integration) and the
|
||||
end-to-end **smoke** tests; a PR labelled `e2e:full`, a push to `main` and a
|
||||
manual run add all 48 walks and the whole end-to-end suite; a published release
|
||||
runs that full set before its image is built. The end-to-end tests drive the
|
||||
real agent, every role on a local model (so they cost nothing per call), in a
|
||||
repository of the run's own, with a scripted operator; CI runs them 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
|
||||
@@ -271,8 +275,9 @@ in prose, asking 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.* **Built** (this PR);
|
||||
accepted once a tag's image passes the e2e suite.
|
||||
**M1.5 The agent as a container image.** *Pre-approved.* **Done** (#115; accepted
|
||||
2026-10-07: `v0.1.0`'s published image passed the whole e2e suite, 9 of 9, run with
|
||||
the release's own tests over it).
|
||||
- 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.
|
||||
@@ -283,6 +288,10 @@ accepted once a tag's image passes the e2e suite.
|
||||
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).
|
||||
- Acceptance runs the tests over a published image: `docker build --target test
|
||||
--build-arg TEST_BASE=gitea.dongar.net/dev/nisse-dev:<version>` from the
|
||||
release's checkout, then `scripts/e2e.sh --image`; the test stage adds only the
|
||||
test tools, so the agent under test is the one published.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
@@ -414,6 +415,15 @@ class Delegation:
|
||||
# and its tree state is unknown. Treat it as a failure rather
|
||||
# than inventing a handback on its behalf.
|
||||
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
|
||||
return DelegationResult(
|
||||
"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:
|
||||
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
|
||||
build plans under docs/plans/ before you restate.
|
||||
|
||||
@@ -587,6 +591,8 @@ class LoopAgent:
|
||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||
elif new and item.position == WAITING:
|
||||
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 == READY:
|
||||
loop = replace(loop, wait="", note="")
|
||||
@@ -824,6 +830,19 @@ class LoopAgent:
|
||||
elif not self._store.active_item():
|
||||
self._transport.post("Nothing is running.", thread=msg.thread)
|
||||
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(
|
||||
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||
)
|
||||
@@ -844,8 +863,6 @@ 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
|
||||
@@ -903,12 +920,13 @@ class LoopAgent:
|
||||
)
|
||||
self._go(item, loop, f"{msg.sender} (in plain language)")
|
||||
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(
|
||||
self._store,
|
||||
item.id,
|
||||
READY,
|
||||
replace(loop, wait="", note=""),
|
||||
replace(loop, wait="", note="", back_to=loop.wait, back_note=loop.note),
|
||||
cause="the operator answered",
|
||||
)
|
||||
self._schedule()
|
||||
@@ -1272,28 +1290,28 @@ class LoopAgent:
|
||||
f"{self._repos()}."
|
||||
)
|
||||
queue = [i.id for i in self._queue()]
|
||||
lines = []
|
||||
for item in items:
|
||||
loop = loop_of(item)
|
||||
what = item.position
|
||||
if item.position == WAITING and loop.wait == CHAT:
|
||||
what = "talking; your turn"
|
||||
elif item.position == WAITING:
|
||||
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
|
||||
if loop.grace_until:
|
||||
left = max(0, int(loop.grace_until - self._clock()) // 60)
|
||||
what += f", going ahead in {left} min unless you reply"
|
||||
elif item.position == BLOCKED:
|
||||
what = f"blocked by {', '.join(loop.blocked_by)}"
|
||||
elif item.position == READY:
|
||||
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
||||
name = (
|
||||
f'conversation "{item.title}"'
|
||||
if item.scope == GENERAL
|
||||
else f"{self._label(item)} {item.title}"
|
||||
)
|
||||
lines.append(f"{name}: {what}")
|
||||
return "\n".join(lines)
|
||||
return "\n".join(self._status_line(item, queue) for item in items)
|
||||
|
||||
def _status_line(self, item: WorkItem, queue: list[str]) -> str:
|
||||
loop = loop_of(item)
|
||||
what = item.position
|
||||
if item.position == WAITING and loop.wait == CHAT:
|
||||
what = "talking; your turn"
|
||||
elif item.position == WAITING:
|
||||
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
|
||||
if loop.grace_until:
|
||||
left = max(0, int(loop.grace_until - self._clock()) // 60)
|
||||
what += f", going ahead in {left} min unless you reply"
|
||||
elif item.position == BLOCKED:
|
||||
what = f"blocked by {', '.join(loop.blocked_by)}"
|
||||
elif item.position == READY:
|
||||
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
||||
name = (
|
||||
f'conversation "{item.title}"'
|
||||
if item.scope == GENERAL
|
||||
else f"{self._label(item)} {item.title}"
|
||||
)
|
||||
return f"{name}: {what}"
|
||||
|
||||
# -- running (L1, L2) ------------------------------------------------------
|
||||
|
||||
@@ -1663,12 +1681,15 @@ class LoopAgent:
|
||||
CLOSE_ITEM,
|
||||
*([WAIT_FOR_REVIEW] if loop.pr else []),
|
||||
*([DELEGATE, ASK_EXPERT] if self._helpers else []),
|
||||
*([REPLY] if loop.back_to else []),
|
||||
*EXECUTOR_TOOLS,
|
||||
]
|
||||
),
|
||||
]
|
||||
if loop.question:
|
||||
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)
|
||||
workspace = project.workspace
|
||||
system = SYSTEM.format(
|
||||
@@ -1771,6 +1792,13 @@ class LoopAgent:
|
||||
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
|
||||
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":
|
||||
text = str(args.get("text", "")).strip()
|
||||
self._transport.post(text, thread=loop.thread)
|
||||
@@ -2068,6 +2096,8 @@ class LoopAgent:
|
||||
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
||||
if say:
|
||||
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}")
|
||||
|
||||
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 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.loop.tools import WAITING, names
|
||||
from nisse_dev.state import Transcript
|
||||
@@ -100,7 +100,10 @@ class Driver:
|
||||
if nudged:
|
||||
return RunOutcome("failed", note="kept replying in prose")
|
||||
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
|
||||
for index, call in enumerate(calls):
|
||||
if self._cancel.requested:
|
||||
|
||||
@@ -22,6 +22,7 @@ from dataclasses import replace
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
GateOpened,
|
||||
PlanSubmitted,
|
||||
@@ -105,6 +106,13 @@ def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
||||
if viewer == "helper":
|
||||
out.append(_said(stored, "approved plan", event.plan))
|
||||
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:
|
||||
out.append(stored)
|
||||
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:
|
||||
# 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):
|
||||
if message["role"] != "assistant":
|
||||
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]
|
||||
@@ -8,6 +8,7 @@ checks every property after every step. A failure prints
|
||||
the seed and the steps, so it replays exactly.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
@@ -46,8 +47,14 @@ from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InboundMessage, InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
SEEDS = range(32)
|
||||
TWO_PROJECT_SEEDS = range(100, 116)
|
||||
# How many walks, and from which seed: all 48 by default; CI runs fewer on a
|
||||
# PR, from a seed that moves with the run number, so PRs still cover new
|
||||
# ground (NISSE_WALK_SEEDS, NISSE_WALK_FIRST). Two thirds walk one project,
|
||||
# a third two.
|
||||
_WALKS = int(os.environ.get("NISSE_WALK_SEEDS") or 48)
|
||||
_FIRST = int(os.environ.get("NISSE_WALK_FIRST") or 0)
|
||||
SEEDS = range(_FIRST, _FIRST + _WALKS * 2 // 3)
|
||||
TWO_PROJECT_SEEDS = range(100 + _FIRST, 100 + _FIRST + _WALKS - _WALKS * 2 // 3)
|
||||
STEPS = 30
|
||||
|
||||
|
||||
@@ -76,8 +83,15 @@ 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:
|
||||
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
|
||||
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 self.rng.random() < 0.1:
|
||||
return [
|
||||
@@ -251,6 +265,7 @@ class Walk:
|
||||
if item and loop:
|
||||
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
|
||||
weights.update({"plain-yes": 2, "wait": 2, "question": 2})
|
||||
weights.update({"status-here": 1})
|
||||
if loop.new_project:
|
||||
weights.update({"done": 2})
|
||||
if item.scope != "general": # conversations aren't named by number
|
||||
@@ -266,6 +281,18 @@ 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 == "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:
|
||||
# L5: a question never changes the item it's asked about.
|
||||
before = (item.position, loop.wait, loop.rev, loop.pr, loop.branch)
|
||||
|
||||
@@ -4,6 +4,7 @@ members = ["nisse-core", "nisse-dev", "nisse-ops"]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-xdist>=3",
|
||||
"ruff>=0.6",
|
||||
]
|
||||
|
||||
@@ -19,6 +20,7 @@ addopts = "--strict-markers --ignore=tests/integration/seed"
|
||||
markers = [
|
||||
"integration: end-to-end tests that exercise installed entry points or external services",
|
||||
"e2e: the whole agent against real services, run after each milestone (scripts/e2e.sh)",
|
||||
"smoke: the end-to-end tests every PR runs; the rest on e2e:full and before a release",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
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
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# The throwaway sshd the tests need (nisse_dev/testing.py), in a CI job.
|
||||
# Skipped when the job's image already has it. Otherwise apt fetches from a
|
||||
# kernel.org mirror rather than archive.ubuntu.com, which has been slow
|
||||
# enough (a few KB/s) to add minutes to every job.
|
||||
set -euo pipefail
|
||||
SUDO=$(command -v sudo || true)
|
||||
if command -v /usr/sbin/sshd > /dev/null && command -v ssh > /dev/null; then
|
||||
echo "sshd already installed"
|
||||
else
|
||||
MIRROR=${APT_MIRROR:-http://mirrors.edge.kernel.org/ubuntu}
|
||||
# Both layouts: deb822 (ubuntu.sources, 24.04+) and sources.list (older).
|
||||
for f in /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list; do
|
||||
[ -f "$f" ] && $SUDO sed -i -E \
|
||||
"s#https?://(archive|security)\.ubuntu\.com/ubuntu/?#$MIRROR/#g" "$f"
|
||||
done
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq openssh-server openssh-client > /dev/null
|
||||
fi
|
||||
$SUDO mkdir -p /run/sshd
|
||||
@@ -23,6 +23,8 @@ if [ "${1:-}" = "--image" ]; then
|
||||
mkdir -p "$runs"
|
||||
secrets=()
|
||||
[ -f .env ] && secrets=(--env-file .env)
|
||||
# The model, if overridden here, reaches the tests inside too.
|
||||
[ -n "${NISSE_E2E_MODEL:-}" ] && secrets+=(-e "NISSE_E2E_MODEL=$NISSE_E2E_MODEL")
|
||||
# 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" \
|
||||
|
||||
@@ -15,8 +15,10 @@ from nisse_dev.llm import RoleMap
|
||||
|
||||
# The end-to-end tests run every role on a local model, which costs nothing
|
||||
# per call; NISSE_E2E_MODEL overrides it (e.g. claude-haiku-4-5 for a quick,
|
||||
# paid run).
|
||||
E2E_MODEL = "qwen3.8-27b"
|
||||
# paid run). The no-thinking alias (LiteLLM: chat_template_kwargs
|
||||
# enable_thinking false): with thinking, some answers ran to 6-8k tokens,
|
||||
# minutes each, and stretched a test from 3.5 to 20 minutes.
|
||||
E2E_MODEL = "qwen3.8-27b-nothink"
|
||||
|
||||
|
||||
def e2e_roles() -> RoleMap:
|
||||
@@ -59,16 +61,14 @@ class FreshRepo:
|
||||
push_key: Path # this repository's deploy key, private half
|
||||
|
||||
|
||||
@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, as the operator would make a
|
||||
project's (D4, D8): seeded with a small Python package, the ``agent``
|
||||
account a collaborator with write access, the default branch protected (pushes only by the
|
||||
owner, an approval to merge), and a deploy key of its own, written under
|
||||
``keys``. Deleted afterwards, unless NISSE_E2E_KEEP_REPOS is set."""
|
||||
name = f"nisse-e2e-{run}"
|
||||
def make_repo(
|
||||
operator: GiteaClient, name: str, keys: Path, *, agent: str, seed: bool = True
|
||||
) -> FreshRepo:
|
||||
"""A repository as the operator would make a project's (D4, D8): seeded
|
||||
with a small Python package (or only a README), the ``agent`` account a
|
||||
collaborator with write access, the default branch protected (pushes only
|
||||
by the owner, an approval to merge), and a deploy key of its own, written
|
||||
under ``keys``. A failure part-way deletes what was made."""
|
||||
full = f"{OWNER}/{name}"
|
||||
operator._request(
|
||||
"POST",
|
||||
@@ -145,8 +145,35 @@ def fresh_repo(
|
||||
"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:
|
||||
if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
|
||||
with contextlib.suppress(GiteaError):
|
||||
operator._request("DELETE", f"/repos/{full}")
|
||||
delete_repo(operator, repo.name)
|
||||
@@ -239,6 +239,7 @@ class Operator:
|
||||
return self.transport.outbound[-1].text if self.transport.outbound else ""
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_the_slice_end_to_end(
|
||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||
) -> None:
|
||||
@@ -501,6 +502,7 @@ def test_a_two_milestone_feature(
|
||||
operator.tidy(issues, prs)
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_a_conversation_becomes_filed_work(
|
||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||
) -> None:
|
||||
@@ -636,6 +638,7 @@ def test_a_conversation_becomes_a_new_project(
|
||||
assert any(f.startswith("docs/plans/") for f in files), files
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_a_question_leaves_the_restatement_up(
|
||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||
) -> None:
|
||||
|
||||
@@ -12,6 +12,7 @@ members = [
|
||||
[manifest.dependency-groups]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "pytest-xdist", specifier = ">=3" },
|
||||
{ name = "ruff", specifier = ">=0.6" },
|
||||
]
|
||||
|
||||
@@ -46,6 +47,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "execnet"
|
||||
version = "2.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -177,6 +187,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "execnet" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
|
||||
Reference in new issue
Block a user