diff --git a/devtools/agent_env.py b/devtools/agent_env.py index 03d964e57..7170b79eb 100644 --- a/devtools/agent_env.py +++ b/devtools/agent_env.py @@ -12,12 +12,35 @@ from collections.abc import Callable, Mapping from pathlib import Path, PurePosixPath -AGENT_PRINCIPAL_ENV = "SINNIXD_PRINCIPAL" +#: The queue runner exports ``AGENTCTL_``; the earlier daemon exported +#: ``SINNIXD_``. Every read accepts both, newest first. +RUNTIME_ENV_PREFIXES = ("AGENTCTL_", "SINNIXD_") +AGENT_PRINCIPAL_ENV = "AGENTCTL_PRINCIPAL" AGENT_PRINCIPAL = "agent-control" AGENT_MAX_PYTEST_WORKERS = 2 HARNESS_RUN_ENV = "POLYLOGUE_PYTEST_RUN_ID" -QUEUE_WORKER_ENV = "SINNIXD_QUEUE_WORKER" -QUEUE_POOL_ENV = "SINNIXD_QUEUE_POOL" +QUEUE_WORKER_ENV = "AGENTCTL_QUEUE_WORKER" +QUEUE_POOL_ENV = "AGENTCTL_QUEUE_POOL" + + +def runtime_env(env: Mapping[str, str], name: str) -> str | None: + """Read a queue-runner variable under any of its prefixes. + + ``name`` is the bare suffix (``"JOB_ID"``) or a full name under either + prefix; the newest prefix wins when both are set. + """ + suffix = name + for prefix in RUNTIME_ENV_PREFIXES: + if name.startswith(prefix): + suffix = name[len(prefix) :] + break + for prefix in RUNTIME_ENV_PREFIXES: + value = env.get(prefix + suffix) + if value is not None: + return value + return None + + QUEUE_WORKER_VALUE = "1" PYTEST_POOL = "pytest" #: Every operation declaring ``pool = "pytest"`` in ``.agentctl/project.toml``, @@ -53,7 +76,7 @@ def _inside_pytest_cgroup(cgroup_reader: Callable[[], str] | None) -> bool: def inside_agent_job(env: Mapping[str, str], *, cgroup_reader: Callable[[], str] | None = None) -> bool: - return env.get(AGENT_PRINCIPAL_ENV) == AGENT_PRINCIPAL or _inside_agent_cgroup(cgroup_reader) + return runtime_env(env, AGENT_PRINCIPAL_ENV) == AGENT_PRINCIPAL or _inside_agent_cgroup(cgroup_reader) def inside_declared_pytest_worker(env: Mapping[str, str], *, cgroup_reader: Callable[[], str] | None = None) -> bool: @@ -64,13 +87,13 @@ def inside_declared_pytest_worker(env: Mapping[str, str], *, cgroup_reader: Call come from ``sinnixd-queue-run``; the pytest slice binds that identity to the pool declared by the operation. """ - if env.get(QUEUE_WORKER_ENV) != QUEUE_WORKER_VALUE or not env.get("SINNIXD_JOB_ID"): + if runtime_env(env, QUEUE_WORKER_ENV) != QUEUE_WORKER_VALUE or not runtime_env(env, "JOB_ID"): return False - declared_pool = env.get(QUEUE_POOL_ENV) + declared_pool = runtime_env(env, QUEUE_POOL_ENV) if declared_pool is not None: if declared_pool != PYTEST_POOL: return False - elif env.get("SINNIXD_OPERATION") not in PYTEST_WORKER_OPERATIONS: + elif runtime_env(env, "OPERATION") not in PYTEST_WORKER_OPERATIONS: return False return _inside_pytest_cgroup(cgroup_reader) diff --git a/devtools/pytest_slot.py b/devtools/pytest_slot.py index f37247056..39337661d 100644 --- a/devtools/pytest_slot.py +++ b/devtools/pytest_slot.py @@ -53,10 +53,13 @@ ] #: Exported into every task started by ``sinnixd-queue-run``. -QUEUE_TASK_ENV: Final = "SINNIXD_JOB_ID" +QUEUE_TASK_ENV: Final = "AGENTCTL_JOB_ID" #: The installed queue runner moves the workload out of pueued.service and #: into the slice selected by the launch document's pool. -QUEUE_RUNNER: Final = "sinnixd-queue-run" +#: The queue runner, newest name first; the daemon-era name still resolves on +#: a host that has not switched. +QUEUE_RUNNERS: Final = ("agentctl-run", "sinnixd-queue-run") +QUEUE_RUNNER: Final = QUEUE_RUNNERS[0] #: Explicit escape, for the hermetic test of this mechanism. SLOT_ESCAPE_ENV: Final = "POLYLOGUE_PYTEST_SLOT" SLOT_HELD: Final = "held" @@ -341,7 +344,10 @@ def _queue( launch_path = root / LAUNCH_DIR / f"pytest-slot-{identity}.json" log_path = root / LAUNCH_DIR / f"pytest-slot-{identity}.log" adder = adder_environment(env) - queue_runner = shutil.which(QUEUE_RUNNER, path=adder.get("PATH") or os.defpath) + queue_runner = next( + (found for name in QUEUE_RUNNERS if (found := shutil.which(name, path=adder.get("PATH") or os.defpath))), + None, + ) if queue_runner is None: raise PytestSlotUnavailableError(REFUSAL.format(reason=f"`{QUEUE_RUNNER}` is not on PATH")) _write_launch( diff --git a/devtools/sinnixd_service_context.py b/devtools/sinnixd_service_context.py index afeab216f..800060324 100644 --- a/devtools/sinnixd_service_context.py +++ b/devtools/sinnixd_service_context.py @@ -17,6 +17,8 @@ from typing import Any from uuid import UUID +from devtools.agent_env import runtime_env + _CGROUP_PATH = Path("/proc/self/cgroup") _PROJECT_ID = "polylogue" @@ -81,14 +83,14 @@ def require_declared_operation_context( environment. This does not replace Sinnixd's exact-head or lease validation. """ env = os.environ if environment is None else environment - job_id = env.get("SINNIXD_JOB_ID", "") + job_id = runtime_env(env, "JOB_ID") or "" unit = _unit_name(job_id) expected = { - "SINNIXD_JOB_ID": job_id, - "SINNIXD_PROJECT_ID": _PROJECT_ID, - "SINNIXD_OPERATION": operation, + "JOB_ID": job_id, + "PROJECT_ID": _PROJECT_ID, + "OPERATION": operation, } - if any(env.get(key) != value for key, value in expected.items()): + if any(runtime_env(env, key) != value for key, value in expected.items()): raise ValueError("Sinnixd service context does not match the declared operation") read_cgroup = cgroup_reader or (lambda: _CGROUP_PATH.read_text(encoding="utf-8")) cgroup = _current_cgroup(read_cgroup) diff --git a/devtools/verify.py b/devtools/verify.py index 8fd2a242c..17141d4de 100644 --- a/devtools/verify.py +++ b/devtools/verify.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any -from devtools.agent_env import refuse_verify_tier +from devtools.agent_env import refuse_verify_tier, runtime_env from devtools.checkout_guard import CheckoutImportMismatchError, assert_polylogue_matches_checkout from devtools.cloud_sentinels import cloud_sentinel_declined from devtools.gate import quick_gates @@ -130,7 +130,7 @@ def _raise_verification_interruption(signum: int, _frame: Any) -> None: def _declared_agentctl_operation(raw_argv: Sequence[str]) -> str | None: - operation = os.environ.get("SINNIXD_OPERATION") + operation = runtime_env(os.environ, "OPERATION") return operation if _AGENTCTL_OPERATION_ARGV.get(operation or "") == tuple(raw_argv) else None diff --git a/devtools/verify_runs.py b/devtools/verify_runs.py index 52d1186a7..426680a51 100644 --- a/devtools/verify_runs.py +++ b/devtools/verify_runs.py @@ -27,6 +27,7 @@ from pathlib import Path from typing import Any +from devtools.agent_env import runtime_env from devtools.pytest_evidence import evaluate_pytest_evidence from devtools.testmon_provision import TESTMON_DATA_RELPATH @@ -263,10 +264,10 @@ def __init__( # semantic status is still decided by this verifier. if agentctl_operation is not None: for field, variable in ( - ("agentctl_job_id", "SINNIXD_JOB_ID"), - ("agentctl_correlation_id", "SINNIXD_CORRELATION_ID"), + ("agentctl_job_id", "JOB_ID"), + ("agentctl_correlation_id", "CORRELATION_ID"), ): - value = os.environ.get(variable) + value = runtime_env(os.environ, variable) if value: self._payload[field] = value # Kept on every receipt so later classification does not depend on a diff --git a/tests/unit/devtools/test_agent_env.py b/tests/unit/devtools/test_agent_env.py index 8cc10f4f6..a1598dd16 100644 --- a/tests/unit/devtools/test_agent_env.py +++ b/tests/unit/devtools/test_agent_env.py @@ -99,3 +99,12 @@ def test_every_declared_pytest_pool_operation_classifies_its_own_worker() -> Non } assert agent_env.inside_declared_pytest_worker(environment, cgroup_reader=deployed_pytest_cgroup), operation + + +def test_runtime_env_prefers_the_agentctl_prefix_and_falls_back() -> None: + """Anti-vacuity: dropping the SINNIXD_ fallback makes the second assertion red.""" + from devtools.agent_env import runtime_env + + assert runtime_env({"AGENTCTL_JOB_ID": "new", "SINNIXD_JOB_ID": "old"}, "JOB_ID") == "new" + assert runtime_env({"SINNIXD_JOB_ID": "old"}, "AGENTCTL_JOB_ID") == "old" + assert runtime_env({}, "JOB_ID") is None