fix: tighten internal semantic compose contracts

This commit is contained in:
2026-08-08 18:47:22 +02:00
parent 320d9ea74e
commit 8f4ec1e1a3
7 changed files with 106 additions and 6 deletions
@@ -52,3 +52,47 @@ Concerns:
- The model bootstrap waits for Ollama readiness and verifies cache state, but the first real cold-start will still take time to download `qwen3-embedding:0.6b`.
- The GPU override requests generic Docker GPU capability only; actual GPU availability remains host/runtime dependent and intentionally stays opt-in.
## Fix round 1 / 5 — 2026-08-08
Rulings applied:
- Kept the Task 1 boundary intact: schema-v3 remains the only operational workspace descriptor shape.
- Did not restore any external semantic fallback for schema-v2 live sessions.
- Treated `PROJECT_STATE.md` as stale documentation for this point, not runtime truth.
Focused schema-v2 evidence:
- Re-ran the existing targeted registry test:
- `cd backend && npx vitest run test/workspace-registry.test.ts -t "lists a schema v2 descriptor as migration_required and refuses to acquire it"`
- Result: pass.
- Evidence from that test:
- schema-v2 descriptors list as `migration_required`
- `acquireSessionRevision("psd-clinical")` rejects with `code: "workspace_invalid"`
- Conclusion: schema-v2 acquisition remains blocked; no external semantic fallback was reintroduced.
Contract consistency fixes:
- Updated `harness/tests/test_local_compose_contract.py` to assert the mandatory internal semantic stack, fixed internal core semantic env, private-service topology, persistent volumes, and Ollama health/dependency contract.
- Updated shell Compose contracts to require:
- Ollama healthcheck on `embedding`
- `embedding-model-init` dependency on `embedding: service_healthy`
- Updated `scripts/unified-deployment-smoke.sh` rendered-contract helper to expect the mandatory internal semantic topology and internal semantic env names, and to reject retired external semantic bindings.
- Updated `scripts/test-task13-runtime-fixtures.sh` to exercise `task13_assert_rendered_contract` for both local and server fixture renders.
Fix round 1 verification:
- RED before implementation:
- `cd harness && .venv/bin/pytest tests/test_local_compose_contract.py -q` failed because `embedding` had no healthcheck.
- `./scripts/test-default-compose.sh` failed because `embedding` had no healthcheck.
- `./scripts/test-unified-compose.sh` failed because `embedding` had no healthcheck.
- `./scripts/test-task13-runtime-fixtures.sh local` failed because `unified-deployment-smoke.sh` still expected `core,frontend`.
- GREEN after implementation:
- `./scripts/test-default-compose.sh`
- `./scripts/test-unified-compose.sh`
- `./scripts/test-internal-semantic-compose.sh`
- `cd harness && .venv/bin/pytest tests/test_local_compose_contract.py -q`
- `./scripts/test-task13-runtime-fixtures.sh local`
- `./scripts/test-task13-runtime-fixtures.sh server`
- `cd backend && npx vitest run test/workspace-registry.test.ts -t "lists a schema v2 descriptor as migration_required and refuses to acquire it"`
- `docker compose --env-file deploy/env/local.env.example -f compose.yaml -f deploy/compose.local.yaml config --format json`
+12 -1
View File
@@ -102,6 +102,17 @@ services:
- "11434"
volumes:
- embedding-models:/root/.ollama
healthcheck:
test:
- CMD-SHELL
- >
/usr/bin/bash -lc "exec 3<>/dev/tcp/127.0.0.1/11434 &&
printf 'GET /api/tags HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3 &&
grep -q '200 OK' <&3"
interval: 15s
timeout: 5s
retries: 20
start_period: 10s
networks:
- thothii
@@ -117,7 +128,7 @@ services:
- ./docker/embedding-model-init.sh:/opt/thoth/embedding-model-init.sh:ro
depends_on:
embedding:
condition: service_started
condition: service_healthy
networks:
- thothii
+25 -3
View File
@@ -8,15 +8,37 @@ def test_local_compose_uses_the_generic_external_endpoint_contract():
compose = yaml.safe_load((root / "compose.yaml").read_text())
local = yaml.safe_load((root / "deploy/compose.local.yaml").read_text())
assert set(compose["services"]) == {"core", "frontend"}
assert set(compose["services"]) == {"core", "frontend", "qdrant", "embedding", "embedding-model-init"}
assert local["services"]["core"]["environment"]["AUTH_MODE"] == "none"
assert local["services"]["core"]["ports"] == ["127.0.0.1:${THOTH_CORE_HTTP_PORT:-8787}:8787"]
assert local["services"]["frontend"]["ports"] == ["127.0.0.1:${THOTH_HTTP_PORT:-8080}:8080"]
environment = compose["services"]["core"]["environment"]
for name in ("THT_DWH_REST_URL", "THT_VEC_REST_URL", "THT_OLLAMA_URL", "THT_LLM_URL"):
for name in ("THT_DWH_REST_URL", "THT_LLM_URL"):
assert name in environment
assert {"settings", "pi-state", "workspace-registry", "sessions"} <= set(compose["volumes"])
assert environment["THT_INTERNAL_QDRANT_URL"] == "http://qdrant:6333"
assert environment["THT_INTERNAL_EMBEDDING_URL"] == "http://embedding:11434"
assert environment["THT_INTERNAL_EMBEDDING_MODEL"] == "qwen3-embedding:0.6b"
assert environment["THT_INTERNAL_EMBEDDING_DIMENSIONS"] == "1024"
for name in ("THT_VEC_REST_URL", "THT_VEC_WRITE_REST_URL", "THT_OLLAMA_URL"):
assert name not in environment
assert {"settings", "pi-state", "workspace-registry", "sessions", "qdrant-data", "embedding-models"} <= set(compose["volumes"])
qdrant = compose["services"]["qdrant"]
assert qdrant["expose"] == ["6333"]
assert "ports" not in qdrant
assert "healthcheck" in qdrant
embedding = compose["services"]["embedding"]
assert embedding["expose"] == ["11434"]
assert "ports" not in embedding
assert "healthcheck" in embedding
model_init = compose["services"]["embedding-model-init"]
assert "ports" not in model_init
assert model_init["depends_on"]["embedding"]["condition"] == "service_healthy"
assert compose["services"]["core"]["depends_on"]["embedding-model-init"]["condition"] == "service_completed_successfully"
def test_core_image_prepares_the_writable_pi_profile_before_mounting_config_files():
+5
View File
@@ -40,6 +40,8 @@ for (const service of [qdrant, embedding, modelInit]) {
}
if ((qdrant.expose || []).join(",") !== "6333") throw new Error("qdrant must expose only 6333");
if ((embedding.expose || []).join(",") !== "11434") throw new Error("embedding must expose only 11434");
if (!qdrant.healthcheck) throw new Error("qdrant must define a healthcheck");
if (!embedding.healthcheck) throw new Error("embedding must define a healthcheck");
if (qdrant.image !== "qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c") {
throw new Error("qdrant image must be pinned by version and digest");
}
@@ -72,6 +74,9 @@ if (depends.qdrant?.condition !== "service_healthy") {
if (depends["embedding-model-init"]?.condition !== "service_completed_successfully") {
throw new Error("core must wait for embedding-model-init success");
}
if (modelInit.depends_on?.embedding?.condition !== "service_healthy") {
throw new Error("embedding-model-init must wait for embedding health");
}
if (JSON.stringify(embedding).includes('"devices"')) {
throw new Error("base embedding service must stay CPU-only");
}
+3
View File
@@ -19,6 +19,7 @@ TASK13_ROOT="$root"
TASK13_TMP="$fixture"
TASK13_RUN_ID="fixture-$profile"
TASK13_PROJECT="thothii-task13-$profile"
TASK13_PROFILE="$profile"
TASK13_CORE_IMAGE="task13-core-$profile:fixture"
TASK13_FRONTEND_IMAGE="task13-frontend-$profile:fixture"
TASK13_SECRET_VALUE="task13-runtime-secret-$profile"
@@ -36,6 +37,7 @@ TASK13_PI_SETTINGS="$fixture/settings.json"
TASK13_LLM_SERVER="$fixture/fake-llm.mjs"
TASK13_LLM_CONTAINER="$TASK13_PROJECT-llm"
TASK13_REMOTE="$fixture/remote.git"
TASK13_CURRENT_IMAGE_OVERRIDE="$fixture/current-image.yaml"
mkdir -p "$TASK13_REMOTE"
workspace="$fixture/task13-smoke.yaml"
@@ -94,6 +96,7 @@ fi
rendered="$fixture/rendered.json"
docker compose --project-name "$TASK13_PROJECT" --project-directory "$root" \
--env-file "$TASK13_ENV_FILE" "${compose_files[@]}" config --format json >"$rendered"
task13_assert_rendered_contract
tsx_loader="$root/backend/node_modules/tsx/dist/loader.mjs"
checker=(node --import "$tsx_loader" "$root/scripts/task13-runtime-fixture-check.ts")
[[ -f "$tsx_loader" ]] || {
+9
View File
@@ -75,12 +75,17 @@ for (const serviceName of ["qdrant", "embedding", "embedding-model-init"]) {
}
if ((config.services.qdrant.expose || []).join(",") !== "6333") throw new Error("qdrant must expose only 6333");
if ((config.services.embedding.expose || []).join(",") !== "11434") throw new Error("embedding must expose only 11434");
if (!config.services.qdrant.healthcheck) throw new Error("qdrant must define a healthcheck");
if (!config.services.embedding.healthcheck) throw new Error("embedding must define a healthcheck");
if (config.services.core.depends_on?.qdrant?.condition !== "service_healthy") {
throw new Error("core must wait for qdrant health");
}
if (config.services.core.depends_on?.["embedding-model-init"]?.condition !== "service_completed_successfully") {
throw new Error("core must wait for embedding-model-init success");
}
if (config.services["embedding-model-init"].depends_on?.embedding?.condition !== "service_healthy") {
throw new Error("embedding-model-init must wait for embedding health");
}
if (JSON.stringify(config.services.embedding).includes('"devices"')) {
throw new Error("base embedding service must stay CPU-only");
}
@@ -190,6 +195,10 @@ if (config.services.core.depends_on?.qdrant?.condition !== "service_healthy") {
if (config.services.core.depends_on?.["embedding-model-init"]?.condition !== "service_completed_successfully") {
throw new Error("core must wait for embedding-model-init success");
}
if (!config.services.embedding.healthcheck) throw new Error("embedding must define a healthcheck");
if (config.services["embedding-model-init"].depends_on?.embedding?.condition !== "service_healthy") {
throw new Error("embedding-model-init must wait for embedding health");
}
if (/docker\.sock|\/var\/run\/docker|docker[-_]?daemon/i.test(JSON.stringify(config.services))) {
throw new Error("Compose must not mount the Docker socket or daemon");
}
+8 -2
View File
@@ -518,7 +518,8 @@ task13_build_thothctl() {
task13_assert_rendered_contract() {
local services rendered
services="$(task13_compose config --services | sort)"
[[ "$services" == $'core\nfrontend' ]] || task13_fail "rendered stack is not exactly core and frontend"
[[ "$services" == $'core\nembedding\nembedding-model-init\nfrontend\nqdrant' ]] \
|| task13_fail "rendered stack is not the mandatory internal semantic topology"
rendered="$TASK13_TMP/rendered-compose.yaml"
task13_compose config >"$rendered"
if grep -Eqi 'docker\.sock|/var/run/docker' "$rendered"; then
@@ -527,9 +528,14 @@ task13_assert_rendered_contract() {
if grep -Fq "$TASK13_SECRET_VALUE" "$rendered"; then
task13_fail "rendered Compose exposed the fixture secret"
fi
for endpoint in THT_DWH_REST_URL THT_VEC_REST_URL THT_OLLAMA_URL THT_LLM_URL; do
for endpoint in THT_DWH_REST_URL THT_LLM_URL \
THT_INTERNAL_QDRANT_URL THT_INTERNAL_EMBEDDING_URL \
THT_INTERNAL_EMBEDDING_MODEL THT_INTERNAL_EMBEDDING_DIMENSIONS; do
grep -Fq "$endpoint" "$rendered" || task13_fail "rendered Compose lacks $endpoint"
done
if grep -Eq 'THT_VEC_REST_URL|THT_VEC_WRITE_REST_URL|THT_OLLAMA_URL' "$rendered"; then
task13_fail "rendered Compose still exposes retired external semantic bindings"
fi
}
task13_start_stack() {