feat(4d): hostet inngang — main.py wrapper rundt run_project på én asyncio-løkke

To målinger avgjorde formen FØR koden: (1) hosting-pakkas InvocationsHostServer
finnes kun i bygg som krever agent-framework-core>=1.13.0 (treet låser 1.9.0;
eneste 1.9-kompatible bygg er en forlatt alfa som importerer mcp udeklarert),
(2) et gjenbrukt bygget workflow er single-use på 1.9.0 (kall-serie [2,0,0] —
rundetaket persisterer; ferskt objekt per kall er ren kontroll). Derfor spikens
§5-fallback: hosting.py serverer kontrakten (8088/PORT, /readiness,
/invocations, SIGTERM→0) selv, stdlib asyncio på ÉN løkke — aldri as_agent()
(gatene ligger utenfor grafen), aldri tråder (NG1-guarden fanget første utkast
med ThreadingHTTPServer; asyncio-formen består den by construction).

Payload whitelistes på run_projects signatur — ukjente felt nektes ved navn
(400), aldri stille droppet; profile defaulter til azure kun her. ValueError →
400, alt annet → 500 {error_type, error}; Rejection er vellykket kjøring → 200.
outbox.outcome_payload ekstrahert som den ENE kopien av validated/rejected-
forgreningen (kø-(p)-regelen). azure.yaml validert GRØNN mot begge autoritative
skjemaer (jsonschema, hentet ferskt); ingen env:, ingen startupCommand (imagets
CMD er den ene kopien). Dockerfile: 3.12-slim-bookworm + git + uv==0.9.8 +
uv sync --frozen --no-dev; git archive <indeks-tre> | docker build
--platform linux/amd64 grønn på nøyaktig de stagede bytene.

Iron Law fulgt: testfila rød ved collection FØR modulen fantes. 835 passed /
4 skipped (fra 821), ruff+format+mypy rene. Seks mutasjoner mot HELE suiten,
alle røde på riktig test: detach felt-mappingen · dropp ukjente felt stille ·
flipp 400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach
main.py-shimen (de to siste kun fanget av subprosess-testen, P4-presedensen).
Deploy IKKE utført — azd-steget er operatørens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEiiSGRShizKc771ZBa1iq
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 23:31:02 +02:00
commit 426ccb0ad6
8 changed files with 766 additions and 14 deletions

View file

@ -163,6 +163,34 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
presedensen. **Fail-fast-testen ble skrevet VAKUØS først** (repoets 08-09-klasse):
`PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER `FOUNDRY_PROJECT_ENDPOINT`, så asserten på det
injiserte navnet var oppfylt av vårt eget; den fjerner nå vårt navn før den sjekker.
- **Hostet inngang er en WRAPPER rundt `run_project` på ÉN asyncio-løkke (Fase 4d):** `main.py`
`hosting.py` serverer hosting-kontrakten (port 8088/`PORT` på truthiness, `GET /readiness`,
`POST /invocations`, SIGTERM → exit 0) med stdlib asyncio — ALDRI `as_agent()` (validator,
baseline-forankring, checker-gate og ledger ligger UTENFOR grafen, spike §5) og ALDRI tråder
(NG1-guarden: `http.server`s trådvariant ville lagt samtidige kjøringer på OS-tråder der
S3.3-resonnementet ikke holder; samtidige invocations interleaver som koroutiner — samme modell
som `run_portfolio`s bølger, og `/readiness` svarer mens en kjøring venter på modell-I/O, målt).
**Formen er MÅLT, ikke valgt:** hosting-pakkas `InvocationsHostServer` finnes kun i bygg som
krever core>=1.13.0 (treet låser 1.9.0; eneste 1.9-kompatible bygg er en forlatt alfa med defekt
metadata — importerer `mcp` udeklarert), og et gjenbrukt bygget workflow er SINGLE-USE på 1.9.0
(målt kall-serie [2, 0, 0] — rundetaket persisterer i objektet, så gjenbruk gir TOMME kjøringer;
ferskt objekt per kall er ren kontroll). Payloaden whitelistes på `run_project`s signatur —
ukjente felt NEKTES ved navn (400), aldri stille droppet (valg-doc §0-fella anvendt på vår egen
flate); `profile` defaulter til `azure` KUN her (containeren har ingen lokal endpoint;
`run_project`s egen default forblir LOCAL). Feilmapping ærlig: `ValueError` (pydantic-kontrakter
subklasser den) → 400, alt annet → 500 `{error_type, error}` (speiler `RunFailure`), og en
`Rejection` er en VELLYKKET kjøring → 200 — det negative utfallet tilhører payloaden, aldri
transporten. `outbox.outcome_payload` er den ENE kopien av validated/rejected-forgreningen
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). `azure.yaml`
validert GRØNN mot begge autoritative skjemaer; ingen `env:` (redeklarer aldri
`FOUNDRY_PROJECT_ENDPOINT`), ingen `startupCommand` (imagets `CMD` er den ene kopien av
startkommandoen). `git archive <tree> | docker build --platform linux/amd64 -` grønn på
indeks-treet. Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), seks mutasjoner alle
røde mot hele suiten på riktig test: detach felt-mappingen · dropp ukjente felt stille · flipp
400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach main.py-shimen (de to
siste fanges KUN av subprosess-testen — P4-presedensen). Deploy er IKKE utført (azd-steget er
operatørens); chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
står readiness — uttalt, ikke skjult.
- **Stoppkriterier + budsjett-tak påkrevd ved oppstart** (fail-fast, aldri ubegrenset loop).
- **Group Chat maker-checker** som debatt-default (IKKE Magentic, som er eksperimentell).
- **To falsifiserere, samme kandidat (Steg 3/4, målbilde §2/§6):** den deterministiske validatoren

35
Dockerfile Normal file
View file

@ -0,0 +1,35 @@
# Hosted-agent container (Fase 4d). Build context is `git archive HEAD` — what a stranger
# actually downloads, never the working tree — and the hosting platform requires x86_64
# (spike §1.4), so the measured build command is:
#
# git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -
#
# Base: the version pinned by .python-version (3.12), mid-band of the resolution matrix
# (3.103.13 have numpy wheels, 3.14 does not — spike §4), and the same base Microsoft's
# own MAF sample uses. Runs as root like that sample: no non-root requirement is
# documented (spike, Ikke verifisert pkt. 7).
FROM python:3.12-slim-bookworm
# git: the two security components are git-tag-pinned direct references, and
# [tool.uv.sources] never reaches wheel metadata (spike §3/§7.2) — uv needs the git CLI
# to fetch them, and -slim ships without it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
# Pinned to the uv the tree was measured with (13.08) — an unpinned installer would let
# the container's resolver drift from the working tree's.
RUN pip install --no-cache-dir uv==0.9.8
WORKDIR /app
COPY . .
# The LOCKED, dev-free environment — the same resolution the working tree was measured
# with (agent-framework-core 1.9.0). Never a fresh resolve from pyproject.toml: that
# picks other versions (1.13.0) than every measurement in this repo ran against (§3).
RUN uv sync --frozen --no-dev
# The hosted-agent runtime contract: port 8088, GET /readiness, POST /invocations,
# SIGTERM shutdown (spike §1.1) — served by main.py (portfolio_optimiser.hosting).
EXPOSE 8088
CMD ["/app/.venv/bin/python", "main.py"]

View file

@ -430,6 +430,37 @@ only an expert has:
- **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching
frameworks does not orphan the organization's curated knowledge.
## Hosted on Foundry Agent Service
The repo ships the three files a hosted-agent deployment needs. All three are measured —
schema-validated and container-built — but an actual `azd` deploy has **not** been run; that
step is the operator's.
- `main.py` — the ONE process entry. It serves the hosted-agent runtime contract (port 8088 /
`PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from
`portfolio_optimiser.hosting`, wrapping `run_project` — never `Workflow.as_agent()`, which
would bypass the deterministic validator and the rest of the gate chain. No protocol library
is used: the prerelease `agent-framework-foundry-hosting` requires
`agent-framework-core>=1.13.0` while this tree locks 1.9.0, so the contract is a small
stdlib-asyncio server instead (single event loop, no threads — the same concurrency model as
the portfolio waves).
- `Dockerfile``python:3.12-slim-bookworm` + git (the two security components are
git-tag-pinned direct references; wheel metadata alone cannot install them) +
`uv sync --frozen --no-dev` (the exact locked resolution every measurement ran against).
Build it the way a stranger would:
`git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -`
- `azure.yaml` — closed against the two authoritative JSON schemas (see
`docs/2026-08-13-fase4-azure-yaml-valg.md`): `language: docker`, invocations protocol pinned
to `2.0.0`, no `env:` block (the platform injects `FOUNDRY_PROJECT_ENDPOINT`; model choice is
config, not environment), no `startupCommand` (the image `CMD` is the one copy).
An invocation is a JSON object whitelisted onto `run_project`'s signature — `project_id`,
`docs_dir` and `verdict_input` required; `bundle_dir`, `profile`, `max_rounds`, `max_tokens`
and `top_k` optional. Unknown fields are refused by name (400), never silently dropped.
`profile` defaults to `azure` on this surface: the AZURE profile reads its endpoint and
credential from the environment at call time, so the same image runs hosted (managed identity)
and locally (`az login`) without rewiring.
## Docs
- [Bestille en kjøring](docs/bestille-en-kjoring.md) *(norsk)* — for the domain expert who

28
azure.yaml Normal file
View file

@ -0,0 +1,28 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
#
# Fase 4d. The four open choices were closed against the two authoritative JSON schemas
# (docs/2026-08-13-fase4-azure-yaml-valg.md), not against Learn prose:
#
# * `language: docker`, NO `codeConfiguration` — codeConfiguration is ZIP-source mode,
# and the git-pinned dependencies need our own Dockerfile (git in the base image).
# * `protocols` pinned to invocations 2.0.0, stated outright: the header contract in
# spike §1.5 (x-agent-user-id / x-agent-foundry-call-id, gateway allowlist) is a
# 2.0.0 property, and 1.0.0 is deprecated with a blocking deadline.
# * `project` is REQUIRED for host azure.ai.agent (the $ref'd azure.ai.agent.json);
# `config:` is deprecated there and not used.
# * NO `env:` block, deliberately: FOUNDRY_PROJECT_ENDPOINT must never be redeclared
# (it would risk shadowing the platform's injected value — spike pkt. 2), and model
# selection is config (the packaged data/model_map.json, override via
# PORTFOLIO_MODEL_MAP), not environment variables.
# * NO `startupCommand`, deliberately: the image's CMD (Dockerfile) is the ONE copy of
# the start command — a second copy here could drift from it.
name: portfolio-optimiser
services:
portfolio-optimiser:
host: azure.ai.agent
kind: hosted
project: .
language: docker
protocols:
- protocol: invocations
version: "2.0.0"

13
main.py Normal file
View file

@ -0,0 +1,13 @@
"""Hosted-agent process entry (Fase 4d): the ONE file the container starts.
Thin by design the runtime contract lives in ``portfolio_optimiser.hosting`` (packaged
and tested); this file is the scaffold-conventional ``main.py`` that the Dockerfile ``CMD``
and ``azure.yaml`` point at. It is part of the git archive (the build context), not of the
wheel: a wheel consumer starts the same server with
``python -c "from portfolio_optimiser.hosting import main; main()"``.
"""
from portfolio_optimiser.hosting import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,221 @@
"""Hosted entrypoint (Fase 4d): the Foundry hosted-agent runtime contract implemented
DIRECTLY around ``run_project`` a wrapper, never ``Workflow.as_agent()``.
The form was decided by TWO measurements (13.08), not preference:
* ``agent-framework-foundry-hosting``'s ``InvocationsHostServer`` exists only in builds
requiring ``agent-framework-core>=1.13.0`` (this tree locks 1.9.0); the sole
1.9-compatible build (``1.0.0a260618``) ships broken metadata it imports ``mcp``
without declaring it and is superseded. With no usable protocol library, the runtime
contract (port 8088/``PORT``, ``GET /readiness``, ``POST /invocations``, SIGTERM
shutdown spike §1.1) is served HERE, including ``/readiness``, which a protocol
library would otherwise have provided.
* A BUILT workflow is single-use on core 1.9.0 (measured: client-call series [2, 0, 0]
across three ``.run()`` calls on ONE object the round cap persists in the object, so
reuse yields EMPTY runs, not just contaminated ones). A long-lived hosted process must
therefore never hold a workflow; every invocation goes through ``run_project``, which
builds a fresh one per call (the B7 factory).
``as_agent()`` alone would also serve UNGATED proposals: the deterministic validator,
baseline anchoring, checker gate, ledger and learning loop all live OUTSIDE the Workflow
graph (spike §5) wrapping the graph wraps the wrong boundary.
**The server is asyncio on the ONE loop no threads, by NG1.** ``http.server``'s
threading variant would put concurrent ``run_project`` calls on OS threads, where none of
S3.3's determinism reasoning holds and MAF's thread-safety is undocumented exactly what
``test_no_thread_or_process_path_exists_under_src`` ratchets against. ``asyncio.start_server``
plus ~40 lines of HTTP/1.1 parsing keeps liveness (``/readiness`` answers while an
invocation awaits model I/O) and stays inside the sanctioned concurrency model: concurrent
invocations interleave as coroutines, the same way ``run_portfolio``'s waves do. Honest
limit: during a CPU-bound stretch (the CBC solve) the loop and thus readiness stalls
for that stretch; chunked request bodies are not supported (``Content-Length`` only).
Surface: ``POST /invocations`` takes a JSON object whitelisted onto ``run_project``'s
signature. ``profile`` defaults to ``"azure"`` on THIS surface only (``run_project``'s own
default stays LOCAL): a hosted container has no local OpenAI-compatible endpoint, and the
AZURE profile reads its environment at call time (Fase 4b). Validation, never repair: an
unknown field is a 400 naming the field the permissive-schema trap (valg-doc §0) applied
to our own surface. Error mapping is honest: ``ValueError`` (pydantic contract violations
subclass it) 400; any other failure 500 ``{error_type, error}`` (mirrors
``RunFailure``); a ``Rejection`` is a SUCCESSFUL run 200 with ``outcome_type:
"rejected"`` the negative outcome belongs to the payload, never to the transport. The
platform's injected headers (``x-agent-user-id``/``x-agent-foundry-call-id``) are absent
locally by contract and unused here; forwarding the call-id on outgoing Foundry calls has
no seam in ``backends.py`` today and is deliberately not built (90 %-prinsippet).
"""
from __future__ import annotations
import asyncio
import json
import os
import signal
from typing import Any
from portfolio_optimiser.outbox import outcome_payload
from portfolio_optimiser.run import RunResult, run_project
DEFAULT_PORT = 8088
_HOSTED_DEFAULT_PROFILE = "azure"
_REQUIRED_FIELDS = ("project_id", "docs_dir", "verdict_input")
_OPTIONAL_FIELDS = ("bundle_dir", "profile", "max_rounds", "max_tokens", "top_k")
_ALLOWED_FIELDS = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS)
_REASONS = {200: "OK", 400: "Bad Request", 404: "Not Found", 500: "Internal Server Error"}
class InvocationRefused(ValueError):
"""A request the invocations contract refuses — unknown, missing or non-object input.
Validation, never repair (``write_concept_file`` precedent): nothing is dropped,
defaulted or corrected on the caller's behalf."""
def resolve_port() -> int:
"""PORT on truthiness, not presence (the 4b rule): an exported-empty PORT is a shell
accident, not a bind instruction. 8088 is the hosted-agent contract's port."""
return int(os.environ.get("PORT") or DEFAULT_PORT)
def _run_kwargs(payload: Any) -> tuple[str, dict[str, Any]]:
"""Whitelist the JSON payload onto ``run_project``'s signature. Everything not named in
the whitelist including server-side seams like ``outbox_dir``, ``client_factory`` or
``verdict_dir`` is refused by name, never silently dropped."""
if not isinstance(payload, dict):
raise InvocationRefused("body must be a JSON object")
unknown = sorted(set(payload) - _ALLOWED_FIELDS)
if unknown:
raise InvocationRefused(f"unknown field(s): {', '.join(unknown)}")
missing = [field for field in _REQUIRED_FIELDS if field not in payload]
if missing:
raise InvocationRefused(f"missing required field(s): {', '.join(missing)}")
kwargs: dict[str, Any] = {k: payload[k] for k in payload if k != "project_id"}
kwargs.setdefault("profile", _HOSTED_DEFAULT_PROFILE)
return payload["project_id"], kwargs
def _response_payload(result: RunResult) -> dict[str, Any]:
return {
**outcome_payload(
result.outcome,
checker_verdict=result.checker_verdict,
verdict_id=result.verdict.id,
),
"proposal": result.outcome.proposal.model_dump(),
"provenance": result.provenance.model_dump(),
"refinements": [rejection.reason for rejection in result.refinements],
}
async def invoke(payload: Any) -> dict[str, Any]:
"""One invocation: validate → ``run_project`` → outbox-shaped response payload.
``run_project`` is resolved through this module's namespace at call time (the test
seam). ``live_dry_run`` is not on the whitelist, so the union narrows to RunResult."""
project_id, kwargs = _run_kwargs(payload)
result = await run_project(project_id, **kwargs)
assert isinstance(result, RunResult)
return _response_payload(result)
def _http_response(status: int, content_type: str, body: bytes) -> bytes:
head = (
f"HTTP/1.1 {status} {_REASONS[status]}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n"
)
return head.encode("latin-1") + body
def _json_response(status: int, payload: dict[str, Any]) -> bytes:
body = (json.dumps(payload, sort_keys=True) + "\n").encode("utf-8")
return _http_response(status, "application/json", body)
async def _read_request(
reader: asyncio.StreamReader,
) -> tuple[str, str, bytes] | None:
"""Parse one HTTP/1.1 request: (method, path, body), or None when unparseable. The
platform terminates TLS and speaks plain HTTP/1.1 (spike §1.1); ``Content-Length`` is
required for a body chunked transfer is not supported (an honest MVP limit)."""
request_line = await reader.readline()
parts = request_line.decode("latin-1", errors="replace").split()
if len(parts) != 3:
return None
method, path = parts[0], parts[1]
content_length = 0
while True:
line = await reader.readline()
if line in (b"\r\n", b"\n", b""):
break
name, _, value = line.decode("latin-1", errors="replace").partition(":")
if name.strip().lower() == "content-length":
try:
content_length = int(value.strip())
except ValueError:
return None
body = b""
if content_length > 0:
try:
body = await reader.readexactly(content_length)
except asyncio.IncompleteReadError:
return None
return method, path, body
async def _respond(method: str, path: str, body: bytes) -> bytes:
if method == "GET" and path == "/readiness":
return _http_response(200, "text/plain", b"ok\n")
if method == "POST" and path == "/invocations":
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return _json_response(400, {"error": "body is not valid JSON"})
try:
return _json_response(200, await invoke(payload))
except ValueError as exc:
# The caller's error: InvocationRefused + run_project's fail-fast contract
# violations (pydantic ValidationError subclasses ValueError).
return _json_response(400, {"error": str(exc)})
except Exception as exc:
# The run's failure, answered rather than dropped — RunFailure's honest shape
# (error_type + text), so BudgetExceeded reads as what it is, not as a 400.
return _json_response(500, {"error_type": type(exc).__name__, "error": str(exc)})
return _json_response(404, {"error": f"no such path: {path}"})
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
request = await _read_request(reader)
if request is None:
writer.write(_json_response(400, {"error": "malformed HTTP request"}))
else:
writer.write(await _respond(*request))
await writer.drain()
except (ConnectionError, asyncio.CancelledError):
pass # client went away / server shutting down — nothing to answer
finally:
writer.close()
try:
await writer.wait_closed()
except ConnectionError:
pass
async def start_server(host: str, port: int) -> asyncio.AbstractServer:
"""The contract server on the CURRENT loop, bindable to port 0 for tests."""
return await asyncio.start_server(_handle, host, port)
async def _serve_until_sigterm() -> None:
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
server = await start_server("0.0.0.0", resolve_port())
async with server:
await stop.wait()
# Leaving the context closes the listening socket; in-flight handlers already hold
# their connections and finish on the loop before asyncio.run() tears it down.
def main() -> None:
"""Serve the hosted-agent contract until SIGTERM (bind 0.0.0.0 — the platform
terminates TLS in front of us), then exit 0."""
asyncio.run(_serve_until_sigterm())

View file

@ -88,9 +88,33 @@ def write_outbox(
encoding="utf-8",
)
outcome_path = directory / f"{stem}-outcome.json"
outcome_path.write_text(
_dump(
{
**keys,
**outcome_payload(outcome, checker_verdict=checker_verdict, verdict_id=verdict_id),
}
),
encoding="utf-8",
)
return proposal_path, outcome_path
def outcome_payload(
outcome: ValidatedProposal | Rejection,
*,
checker_verdict: str | None,
verdict_id: str,
) -> dict[str, Any]:
"""The outcome artefact's payload minus the file keys — the ONE copy of the
validated/rejected branching, shared by ``write_outbox`` and the hosted invocations
response (``hosting._response_payload``). Two copies of the branch would drift, and a
drifted copy would let the HTTP surface describe an outcome the outbox never wrote
the ``to_ore`` single-source rule (-(p)) applied to a payload shape."""
if isinstance(outcome, ValidatedProposal):
outcome_payload: dict[str, Any] = {
**keys,
return {
"outcome_type": "validated",
"p10": outcome.p10,
"p50": outcome.p50,
@ -99,18 +123,12 @@ def write_outbox(
"checker_verdict": checker_verdict,
"verdict_id": verdict_id,
}
else:
outcome_payload = {
**keys,
"outcome_type": "rejected",
"reason": outcome.reason,
"checker_verdict": checker_verdict,
"verdict_id": verdict_id,
}
outcome_path = directory / f"{stem}-outcome.json"
outcome_path.write_text(_dump(outcome_payload), encoding="utf-8")
return proposal_path, outcome_path
return {
"outcome_type": "rejected",
"reason": outcome.reason,
"checker_verdict": checker_verdict,
"verdict_id": verdict_id,
}
def write_run_config(

View file

@ -0,0 +1,378 @@
"""Fase 4d hosted entrypoint (``hosting.py`` + root ``main.py``): the Foundry hosted-agent
runtime contract implemented DIRECTLY around ``run_project`` the wrapper form BOTH 13.08
measurements prescribe (hosting-pakka: ``InvocationsHostServer`` finnes kun i bygg som krever
core>=1.13.0; gjenbrukt workflow: kall-serie [2, 0, 0] single-use 1.9.0).
Load-bearing surface pinned here:
* ``GET /readiness`` 200. We use NO protocol library (the measurement above), so the endpoint
the platform health-checks is OURS to serve nothing serves it for us.
* ``POST /invocations`` wires the payload's whitelisted fields into ``run_project`` and returns
the outbox-shaped outcome payload. ``profile`` defaults to ``"azure"`` on THIS surface (a
hosted container has no local endpoint; run_project's own default stays LOCAL).
* Validation, NEVER repair: an unknown field is a 400 naming the field the permissive-schema
trap (valg-doc §0: "en fil som ser konfigurert ut og ikke er det") applied to our own surface.
* Honest error mapping: ``ValueError`` (incl. pydantic contract violations) 400; run failures
500 ``{error_type, error}`` (mirrors ``RunFailure``); a ``Rejection`` is a SUCCESSFUL run
200 the negative outcome belongs to the payload, never to the transport.
* The server is asyncio on the ONE loop (NG1: ``test_no_thread_or_process_path_exists_under_src``
ratchets src/ thread-free) these tests run client and server as coroutines on the SAME loop,
which only works because nothing in the server blocks it.
* Root ``main.py`` is the ONE process entry (Dockerfile CMD + azure.yaml point at it): the
subprocess test is the ONLY test that catches a detached shim or a detached SIGTERM handler
(P4-presedensen: entry-point-mutasjoner fanges aldri av in-process-tester).
"""
from __future__ import annotations
import asyncio
import json
import os
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
import pytest
from portfolio_optimiser import hosting
from portfolio_optimiser.budget import BudgetExceeded
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
from portfolio_optimiser.retrieval import TextSpan
from portfolio_optimiser.run import RunResult
from portfolio_optimiser.validator import Rejection, ValidatedProposal
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, VerdictStore
_PROPOSAL = SavingsProposal(
project_id="P1",
measure="LED-retrofit av kontorbelysning",
affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=1000.0, unit_cost=1.0)],
claimed_saving_nok=200.0,
assumptions={"ENERGI-TOTAL-EL": (0.8, 1.2)},
)
_PROVENANCE = ProvenanceStamp(
citations=[
Citation(file="f.md", locator=TextSpan(start_index=0, end_index=5), snippet="hello")
],
model="synthetic",
role="proposer",
validator_decision="validated",
token_usage=8,
)
_VALIDATED = ValidatedProposal(
proposal=_PROPOSAL, p10=100.0, p50=150.0, p90=200.0, nominal_feasible=180.0
)
_REJECTION = Rejection(proposal=_PROPOSAL, reason="stage 0: unknown cost code")
_VERDICT = Verdict(
id="vid-hosted",
proposal_features=ProposalFeatures(
affected_codes=frozenset({"ENERGI-TOTAL-EL"}),
measure_type="LED-retrofit av kontorbelysning",
claimed_saving_nok=200.0,
),
decision="approved",
rationale="expert reviewed",
)
_PAYLOAD = {
"project_id": "P1",
"docs_dir": "docs",
"verdict_input": {"decision": "approved", "rationale": "expert"},
}
def _result(outcome: ValidatedProposal | Rejection) -> RunResult:
return RunResult(
outcome=outcome,
provenance=_PROVENANCE,
verdict=_VERDICT,
retrieved=[],
store=VerdictStore(verdicts=[]),
debate_output="debate",
checker_verdict="approve",
)
class _Recorder:
"""An async stand-in for ``run_project``: records every (args, kwargs), then returns the
configured RunResult or raises the configured error."""
def __init__(
self,
result: RunResult | None = None,
error: Exception | None = None,
) -> None:
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
self._run_result = result
self._error = error
async def __call__(self, *args: Any, **kwargs: Any) -> RunResult:
self.calls.append((args, kwargs))
if self._error is not None:
raise self._error
assert self._run_result is not None
return self._run_result
@pytest.fixture()
async def served() -> Any:
"""The contract server on THIS test's loop (port 0 → ephemeral). Client and server share
the loop, so a server that blocked it would hang these tests the fixture is itself a
check that nothing in the request path blocks."""
server = await hosting.start_server("127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
yield f"127.0.0.1:{port}"
server.close()
await server.wait_closed()
async def _request(
base: str, method: str, path: str, body: bytes | None = None
) -> tuple[int, bytes]:
"""A minimal HTTP/1.1 client on the same loop (urllib would block the shared loop)."""
host, port_text = base.split(":")
reader, writer = await asyncio.open_connection(host, int(port_text))
head = f"{method} {path} HTTP/1.1\r\nHost: {base}\r\nConnection: close\r\n"
if body is not None:
head += f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
writer.write(head.encode("latin-1") + b"\r\n" + (body or b""))
await writer.drain()
raw = await reader.read()
writer.close()
await writer.wait_closed()
status = int(raw.split(b" ", 2)[1])
return status, raw.split(b"\r\n\r\n", 1)[1]
async def _get(base: str, path: str) -> tuple[int, bytes]:
return await _request(base, "GET", path)
async def _post(base: str, path: str, payload: Any) -> tuple[int, dict[str, Any]]:
body = payload if isinstance(payload, bytes) else json.dumps(payload).encode("utf-8")
status, raw = await _request(base, "POST", path, body)
return status, json.loads(raw.decode("utf-8"))
async def test_readiness_returns_200(served: str) -> None:
"""The platform health-checks GET /readiness; with no protocol library, serving it is ours."""
status, _body = await _get(served, "/readiness")
assert status == 200
async def test_unknown_paths_are_404(served: str) -> None:
status, _ = await _get(served, "/other")
assert status == 404
status, _ = await _post(served, "/other", _PAYLOAD)
assert status == 404
async def test_invocations_wires_payload_into_run_project(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The wiring seam: the payload's fields reach run_project as its own arguments, the hosted
default profile is 'azure', and the response carries the outbox-shaped outcome + proposal +
provenance. RED if the handler detaches the runner or drops the field mapping."""
recorder = _Recorder(result=_result(_VALIDATED))
monkeypatch.setattr(hosting, "run_project", recorder)
status, body = await _post(served, "/invocations", {**_PAYLOAD, "max_rounds": 2})
assert status == 200
assert recorder.calls, "run_project was never called"
args, kwargs = recorder.calls[0]
assert args == ("P1",)
assert kwargs["docs_dir"] == "docs"
assert kwargs["verdict_input"] == {"decision": "approved", "rationale": "expert"}
assert kwargs["max_rounds"] == 2
# Hosted default: a container has no local OpenAI-compatible endpoint (run_project's own
# default stays LOCAL — the two defaults are different on purpose, and this pins OURS).
assert kwargs["profile"] == "azure"
assert body["outcome_type"] == "validated"
assert body["p90"] == 200.0
assert body["checker_verdict"] == "approve"
assert body["verdict_id"] == "vid-hosted"
assert body["proposal"]["measure"] == "LED-retrofit av kontorbelysning"
assert body["provenance"]["model"] == "synthetic"
async def test_profile_in_payload_overrides_hosted_default(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
recorder = _Recorder(result=_result(_VALIDATED))
monkeypatch.setattr(hosting, "run_project", recorder)
status, _body = await _post(served, "/invocations", {**_PAYLOAD, "profile": "local"})
assert status == 200
_args, kwargs = recorder.calls[0]
assert kwargs["profile"] == "local"
async def test_unknown_field_is_refused_never_repaired(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Validation, never repair: an unknown field is a 400 naming the field and the runner is
never reached dropping it silently would be the permissive-schema failure mode (valg §0).
The control arm (same payload minus the key) proves the refusal is FOR the key."""
recorder = _Recorder(result=_result(_VALIDATED))
monkeypatch.setattr(hosting, "run_project", recorder)
status, body = await _post(served, "/invocations", {**_PAYLOAD, "outbox_dir": "/x"})
assert status == 400
assert "outbox_dir" in body["error"]
assert recorder.calls == [] # refused BEFORE the runner — never repaired-and-run
control_status, _ = await _post(served, "/invocations", _PAYLOAD)
assert control_status == 200
assert len(recorder.calls) == 1
@pytest.mark.parametrize("missing", ["project_id", "docs_dir", "verdict_input"])
async def test_missing_required_field_is_400(
served: str, monkeypatch: pytest.MonkeyPatch, missing: str
) -> None:
recorder = _Recorder(result=_result(_VALIDATED))
monkeypatch.setattr(hosting, "run_project", recorder)
payload = {k: v for k, v in _PAYLOAD.items() if k != missing}
status, body = await _post(served, "/invocations", payload)
assert status == 400
assert missing in body["error"]
assert recorder.calls == []
async def test_non_object_or_invalid_json_body_is_400(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
recorder = _Recorder(result=_result(_VALIDATED))
monkeypatch.setattr(hosting, "run_project", recorder)
status, _ = await _post(served, "/invocations", b"[1, 2]")
assert status == 400
status, _ = await _post(served, "/invocations", b"not json")
assert status == 400
assert recorder.calls == []
async def test_rejected_outcome_is_200_with_reason(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Rejection is a successful run with a negative outcome — 200, outcome_type=rejected, the
reason, and NO percentile keys (key-absence on the parsed dict, not a substring)."""
recorder = _Recorder(result=_result(_REJECTION))
monkeypatch.setattr(hosting, "run_project", recorder)
status, body = await _post(served, "/invocations", _PAYLOAD)
assert status == 200
assert body["outcome_type"] == "rejected"
assert body["reason"] == "stage 0: unknown cost code"
assert "p90" not in body
async def test_contract_violation_is_400_and_run_failure_is_500(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ValueError (pydantic contract violations subclass it) is the CALLER's error → 400; any
other failure is an honest 500 carrying {error_type, error} (mirrors RunFailure's shape).
BudgetExceeded is RuntimeError, so it lands in the 500 arm with observed != limit so the
two can never be conflated by an echo (-(y))."""
monkeypatch.setattr(
hosting, "run_project", _Recorder(error=ValueError("docs_dir does not exist"))
)
status, body = await _post(served, "/invocations", _PAYLOAD)
assert status == 400
assert "docs_dir does not exist" in body["error"]
monkeypatch.setattr(hosting, "run_project", _Recorder(error=BudgetExceeded("tokens", 100, 173)))
status, body = await _post(served, "/invocations", _PAYLOAD)
assert status == 500
assert body["error_type"] == "BudgetExceeded"
assert "limit=100" in body["error"]
assert "173" in body["error"]
async def test_readiness_answers_while_an_invocation_is_in_flight(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Liveness on ONE loop: while an invocation awaits (model I/O), /readiness must still
answer the platform health-checks during long runs, and a server that serialized the
whole process on one in-flight request would be killed as unready."""
release = asyncio.Event()
class _Blocking(_Recorder):
async def __call__(self, *args: Any, **kwargs: Any) -> RunResult:
await release.wait()
return await super().__call__(*args, **kwargs)
monkeypatch.setattr(hosting, "run_project", _Blocking(result=_result(_VALIDATED)))
in_flight = asyncio.ensure_future(_post(served, "/invocations", _PAYLOAD))
try:
status, _ = await asyncio.wait_for(_get(served, "/readiness"), timeout=10)
assert status == 200
finally:
release.set()
status, _body = await asyncio.wait_for(in_flight, timeout=10)
assert status == 200
def test_port_resolution_is_truthiness_not_presence(monkeypatch: pytest.MonkeyPatch) -> None:
"""PORT on truthiness (the 4b rule): an exported-empty PORT is a shell accident, not a bind
instruction. Default 8088 is the hosted-agent contract's port."""
monkeypatch.delenv("PORT", raising=False)
assert hosting.resolve_port() == 8088
monkeypatch.setenv("PORT", "9001")
assert hosting.resolve_port() == 9001
monkeypatch.setenv("PORT", "")
assert hosting.resolve_port() == 8088
def _blocking_get(url: str) -> tuple[int, str]:
try:
with urllib.request.urlopen(url, timeout=10) as resp:
return resp.status, resp.read().decode("utf-8")
except urllib.error.HTTPError as err:
return err.code, err.read().decode("utf-8")
def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
"""Root main.py is the ONE process entry (Dockerfile CMD + azure.yaml point at it): started
as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that
catches a shim that stops calling hosting.main() or a detached SIGTERM handler."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
repo_root = Path(__file__).resolve().parents[1]
proc = subprocess.Popen(
[sys.executable, str(repo_root / "main.py")],
env={**os.environ, "PORT": str(port)},
cwd=repo_root,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
deadline = time.monotonic() + 60
up = False
while time.monotonic() < deadline:
try:
status, _ = _blocking_get(f"http://127.0.0.1:{port}/readiness")
if status == 200:
up = True
break
except (urllib.error.URLError, OSError):
time.sleep(0.2)
assert up, "main.py never served /readiness"
proc.send_signal(signal.SIGTERM)
assert proc.wait(timeout=15) == 0
finally:
if proc.poll() is None:
proc.kill()
proc.wait()