From c97e216d45a300293638e2562356611b258f4aac Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:51:51 +0200 Subject: [PATCH] fix(plane): ax task names leave room for the template suffix ax names a task's ActorTemplate -tmpl-<8 hex> and Substrate caps names at 63 bytes; validation-46's first task (51 characters) fell back to a missing default template on the live cluster. Task names are capped at 49 with a stable hash suffix when longer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- src/openultrasast/plane/reconciler.py | 20 +++++++++++++++----- tests/test_plane_reconciler.py | 12 ++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/openultrasast/plane/reconciler.py b/src/openultrasast/plane/reconciler.py index 37a0488..b94b2c3 100644 --- a/src/openultrasast/plane/reconciler.py +++ b/src/openultrasast/plane/reconciler.py @@ -23,6 +23,7 @@ State: ``~/ousast-results/plane//`` (``OUSAST_RESULTS``), polled every ``OU from __future__ import annotations import contextlib +import hashlib import json import os import queue @@ -79,8 +80,17 @@ def _read_json(path: Path) -> Any: return None -def _ax_name(*parts: str) -> str: - return re.sub(r"[^a-z0-9-]+", "-", "-".join(parts).lower()).strip("-")[:63] +# ax names a task's ActorTemplate "-tmpl-<8 hex>" and Substrate caps names at 63 bytes (live, 2026-09-29). +TASK_NAME_LIMIT = 63 - len("-tmpl-12345678") + + +def _ax_name(*parts: str, limit: int = 63) -> str: + """An RFC 1123 label; a name over ``limit`` keeps a readable prefix plus a stable hash, so two never collide.""" + name = re.sub(r"[^a-z0-9-]+", "-", "-".join(parts).lower()).strip("-") + if len(name) <= limit: + return name + digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] + return f"{name[: limit - 9].rstrip('-')}-{digest}" def load_run(run_manifest: Path) -> tuple[Run, Manifests]: @@ -131,7 +141,7 @@ def render_task(run: Run, entry: RunTask, manifests: Manifests, artifact_url: st inputs are never rendered: ax puts every bound Workspace into one env value, which Substrate caps at 32768 characters, so the runner fetches them from the receiver (``OUSAST_INPUTS``) after the start.""" task: Task = manifests.tasks[entry.task] - ax_name = _ax_name(run.metadata.name, entry.name) + ax_name = _ax_name(run.metadata.name, entry.name, limit=TASK_NAME_LIMIT) env = {e.name: e.value for e in task.env} env.update(OUSAST_RUN=run.metadata.name, OUSAST_TASK=entry.name, OUSAST_OUTPUT_DIR=f"{OUTPUT_ROOT}/{entry.name}") env.update(OUSAST_ARTIFACT_URL=artifact_url, **({"OUSAST_ARTIFACT_DIAL": dial} if dial else {})) @@ -293,7 +303,7 @@ def _await(ax: Ax, name: str, delivered: Callable[[], bool], start: Callable[[], def _execute( ax: Ax, egress: Egress, run: Run, entry: RunTask, manifests: Manifests, base: Path, receiver: Receiver, router: Router ) -> tuple[str, str]: - ax_name, task = _ax_name(run.metadata.name, entry.name), manifests.tasks[entry.task] + ax_name, task = _ax_name(run.metadata.name, entry.name, limit=TASK_NAME_LIMIT), manifests.tasks[entry.task] atespace, manifest = task.metadata.atespace or "default", base / entry.name / "task.yaml" manifest.parent.mkdir(parents=True, exist_ok=True) docs = render_task(run, entry, manifests, receiver.url, receiver.dial) @@ -337,7 +347,7 @@ def run(run_manifest: Path, *, workers: int = 1, ax: str = "ax", results_root: P def worker(entry: RunTask) -> None: if state.status(entry.name) in ("running", "suspended"): - cli.delete(_ax_name(run_spec.metadata.name, entry.name)) # an interrupted attempt; ax must not keep it + cli.delete(_ax_name(run_spec.metadata.name, entry.name, limit=TASK_NAME_LIMIT)) # an interrupted attempt; ax must not keep it state.set(entry.name, status="running", started=_now(), finished=None) finished.put((entry.name, *_execute(cli, egress, run_spec, entry, manifests, base, receiver, router))) diff --git a/tests/test_plane_reconciler.py b/tests/test_plane_reconciler.py index 5e995ff..dd0f531 100644 --- a/tests/test_plane_reconciler.py +++ b/tests/test_plane_reconciler.py @@ -13,6 +13,7 @@ from __future__ import annotations import io import json +import re import stat import subprocess import sys @@ -865,3 +866,14 @@ def test_a_megabyte_producer_artifact_keeps_every_rendered_value_under_substrate assert len(yaml.safe_dump(task)) < SUBSTRATE_ENV_LIMIT, f"{entry.name}: AX_TASK_YAML" assert len(yaml.safe_dump_all(workspaces)) < SUBSTRATE_ENV_LIMIT, f"{entry.name}: AX_WORKSPACES_YAML" assert all(len(yaml.safe_dump(w)) < SUBSTRATE_ENV_LIMIT for w in workspaces), entry.name + + +def test_ax_task_names_leave_room_for_the_template_suffix() -> None: + """ax names the template "-tmpl-<8 hex>" and Substrate caps names at 63 bytes: a 51-character task name + failed on the live cluster (2026-09-29). Long names keep a prefix plus a stable hash; short ones are unchanged.""" + long = reconciler._ax_name("validation-46", "budibase-mongo-template-nosqli-facts", limit=reconciler.TASK_NAME_LIMIT) + assert len(long) + len("-tmpl-1e472705") <= 63 and long.startswith("validation-46-budibase") + other = reconciler._ax_name("validation-46", "budibase-mongo-template-nosqli-agree", limit=reconciler.TASK_NAME_LIMIT) + assert other != long, "no collision between tasks sharing a long prefix" + assert reconciler._ax_name("run", "facts", limit=reconciler.TASK_NAME_LIMIT) == "run-facts" + assert re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", long)