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:
parent
63eec917d2
commit
426ccb0ad6
8 changed files with 766 additions and 14 deletions
28
CLAUDE.md
28
CLAUDE.md
|
|
@ -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
35
Dockerfile
Normal 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.10–3.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"]
|
||||
31
README.md
31
README.md
|
|
@ -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
28
azure.yaml
Normal 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
13
main.py
Normal 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()
|
||||
221
src/portfolio_optimiser/hosting.py
Normal file
221
src/portfolio_optimiser/hosting.py
Normal 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())
|
||||
|
|
@ -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 (kø-(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(
|
||||
|
|
|
|||
378
tests/test_hosting_loadbearing.py
Normal file
378
tests/test_hosting_loadbearing.py
Normal 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 på 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 (kø-(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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue