From 8f4ec1e1a3c94ecc01f8da954980550d99e610d2 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 8 Aug 2026 18:47:22 +0200 Subject: [PATCH] fix: tighten internal semantic compose contracts --- .../task-7-report.md | 44 +++++++++++++++++++ compose.yaml | 13 +++++- harness/tests/test_local_compose_contract.py | 28 ++++++++++-- scripts/test-default-compose.sh | 5 +++ scripts/test-task13-runtime-fixtures.sh | 3 ++ scripts/test-unified-compose.sh | 9 ++++ scripts/unified-deployment-smoke.sh | 10 ++++- 7 files changed, 106 insertions(+), 6 deletions(-) diff --git a/.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-7-report.md b/.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-7-report.md index 6d756d85..a0d72e66 100644 --- a/.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-7-report.md +++ b/.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-7-report.md @@ -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` diff --git a/compose.yaml b/compose.yaml index 1caa666e..416a76f4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/harness/tests/test_local_compose_contract.py b/harness/tests/test_local_compose_contract.py index e9ea5c7c..525b4ea2 100644 --- a/harness/tests/test_local_compose_contract.py +++ b/harness/tests/test_local_compose_contract.py @@ -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(): diff --git a/scripts/test-default-compose.sh b/scripts/test-default-compose.sh index a88b44ae..fbf15dbc 100755 --- a/scripts/test-default-compose.sh +++ b/scripts/test-default-compose.sh @@ -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"); } diff --git a/scripts/test-task13-runtime-fixtures.sh b/scripts/test-task13-runtime-fixtures.sh index 54f844aa..278240f3 100755 --- a/scripts/test-task13-runtime-fixtures.sh +++ b/scripts/test-task13-runtime-fixtures.sh @@ -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" ]] || { diff --git a/scripts/test-unified-compose.sh b/scripts/test-unified-compose.sh index 79f4ff29..41f6651c 100755 --- a/scripts/test-unified-compose.sh +++ b/scripts/test-unified-compose.sh @@ -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"); } diff --git a/scripts/unified-deployment-smoke.sh b/scripts/unified-deployment-smoke.sh index c1d20a3f..b2c21836 100755 --- a/scripts/unified-deployment-smoke.sh +++ b/scripts/unified-deployment-smoke.sh @@ -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() {