From 3a0e8f600aaf466f51a83448dffdf17ffc18fb03 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 14 Oct 2026 22:57:01 +0300 Subject: [PATCH 1/5] fix: give each baseline provision its own agent name A session-scoped name made a leftover registration fail later cells with name-taken. Co-authored-by: Cursor --- tests/e2e/baseline/README.md | 2 +- tests/e2e/baseline/fixtures/platform.py | 12 +-- tests/e2e/baseline/toolkit/provisioning.py | 90 ++++++++++++++---- .../test_provision_names.py | 95 +++++++++++++++++++ .../test_reap_on_exit.py | 63 ++++++++++++ 5 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 tests/framework_conformance/test_provision_names.py create mode 100644 tests/framework_conformance/test_reap_on_exit.py diff --git a/tests/e2e/baseline/README.md b/tests/e2e/baseline/README.md index e7569d5dd..bc434ddd9 100644 --- a/tests/e2e/baseline/README.md +++ b/tests/e2e/baseline/README.md @@ -402,7 +402,7 @@ The topology is guarded two ways so a mis-wired test never false-greens: | Run the same scenario across every adapter | `@per_adapter()` → `agent` (id via `agent.adapter_id`) | | Run a scenario across a subset | `@per_adapter(Adapter.X, Adapter.Y)` (positional = include) / `@per_adapter(exclude= by id, supports=/without={Capability.MEMORY} by capability, or runs_tool_loop=True for the custom-tool subgroup)`; add `prompt=`/`features=` (or `**MEMORY_AGENT`) to steer | | Drive the agent lifecycle myself (build-only / reboot / rehydration) | `@per_adapter()` → `cell` (`cell.build()` / `cell.provision()` / `cell.run_as()` — see **AdapterCell**) | -| Clean up what I created | nothing: `resource_manager` reaps on teardown (`BAND_E2E_AUTOCLEAN=false` keeps it for debugging) | +| Clean up what I created | nothing: `resource_manager` reaps on teardown (`BAND_E2E_AUTOCLEAN=false` keeps it for debugging). Each provision gets its own name, so a missed reap cannot block the next cell. | | Drive the platform as a user | the `user_ops` fixture (`UserOps`) | | List who the user could invite to a room | `await user_ops.lookup_peers(not_in_room=room_id)` → `list[Peer]` (the invitable roster; `peer_type="Agent"` narrows) | | Drive a peer agent (e.g. the `Echo` bounce) | `await resource_manager.peer(peer).send_message(room_id, "ECHO: ...", mention_id=agent.id, mention_name=agent.name)` — posts as that agent, returns the message id to barrier on (needs the peer already in the room) | diff --git a/tests/e2e/baseline/fixtures/platform.py b/tests/e2e/baseline/fixtures/platform.py index b28ed69a5..3f8d1493a 100644 --- a/tests/e2e/baseline/fixtures/platform.py +++ b/tests/e2e/baseline/fixtures/platform.py @@ -113,18 +113,18 @@ async def resource_manager( ) -> AsyncGenerator[ResourceManager, None]: """Per-test provision/reap driver. - Teardown force-deletes everything provisioned this run, unless - ``BAND_E2E_AUTOCLEAN`` is false (kept for on-purpose debugging; surviving - ids are logged by ``reap_all``/``provision_*``). + Teardown force-deletes everything provisioned this run via + ``reap_on_exit``, unless ``BAND_E2E_AUTOCLEAN`` is false (kept for + on-purpose debugging; surviving ids are logged by ``reap_all`` / + ``provision_*``). """ resources = ResourceManager( user_client=baseline_user_client, settings=baseline_settings, run_id=baseline_run_id, ) - yield resources - if baseline_settings.run.autoclean: - await resources.reap_all() + async with resources.reap_on_exit(enabled=baseline_settings.run.autoclean): + yield resources @pytest.fixture(scope="session", autouse=True) diff --git a/tests/e2e/baseline/toolkit/provisioning.py b/tests/e2e/baseline/toolkit/provisioning.py index 0f6385a65..adb5549e5 100644 --- a/tests/e2e/baseline/toolkit/provisioning.py +++ b/tests/e2e/baseline/toolkit/provisioning.py @@ -69,6 +69,14 @@ # silently truncating a handle. MAX_MENTIONED_LABEL_LEN = len("invitable") # 9 +# Platform ``validate_length(:name, max: 100)``. A longer register is rejected +# for length, which is a different 422 from name-taken. +AGENT_NAME_MAX = 100 + +# ``uuid4().hex`` width. Labels that may exceed the mention cap take the whole +# uuid so a leftover row cannot collide with the next provision of that label. +NAME_TOKEN_HEX = 32 + def run_id_len() -> int: """Hex length for a run id that keeps the longest @mentioned peer name within @@ -76,22 +84,53 @@ def run_id_len() -> int: return MENTION_HANDLE_CAP - len(NAME_PREFIX) - 1 - MAX_MENTIONED_LABEL_LEN +def normalized_label(label: str) -> str: + return label.replace("_", "-") + + def agent_name(*, run_id: str, label: str) -> str: - """The provisioned name for ``label`` in run ``run_id``. + """The provisioned name for ``label`` and name token ``run_id``. Underscores become hyphens: models rewrite ``omp_acp`` as ``omp-acp`` when repeating a name, so an adapter-id label must already be in the form they echo. """ - return f"{NAME_PREFIX}{run_id}-{label.replace('_', '-')}" + return f"{NAME_PREFIX}{run_id}-{normalized_label(label)}" + + +def name_token(label: str) -> str: + """A fresh hex token for one provision of ``label``. + + A peer a scenario @mentions only surfaces its full name inside + ``MENTION_HANDLE_CAP``, so that token uses whatever width the cap still + leaves. Adapter-id labels already pass the cap and take a full uuid slice: + every cell of one adapter would otherwise register the same name, and a + row left behind makes the next cell's register fail with name-taken. + """ + normalized = normalized_label(label) + if len(normalized) <= MAX_MENTIONED_LABEL_LEN: + width = MENTION_HANDLE_CAP - len(NAME_PREFIX) - 1 - len(normalized) + else: + width = min( + NAME_TOKEN_HEX, + AGENT_NAME_MAX - len(NAME_PREFIX) - 1 - len(normalized), + ) + if width < run_id_len(): + raise ValueError( + f"label {normalized!r} leaves {width} characters for a unique name " + f"token; need at least {run_id_len()} within the " + f"{AGENT_NAME_MAX}-character name limit" + ) + return uuid.uuid4().hex[:width] def new_run_id() -> str: - """Short token identifying a single test session's provisioned resources. + """Short token the session fixture draws once for the orphan sweep's self-skip. - Its length is *derived* from the mention-handle cap (see :func:`run_id_len`) so - the longest @mentioned peer name (see :func:`agent_name`) still surfaces in - full rather than as a truncated handle. The resulting entropy - (~1M at 5 hex) is ample given the run-id + age guards in ``sweep_orphans``. + Its length is derived from the mention-handle cap (see :func:`run_id_len`) so + a name that still embeds this token fits the longest @mentioned peer. Each + provision draws its own token via :func:`name_token` rather than reusing this + one. The sweep still skips names containing this session's token; per-provision + names rely on the age gate, the same protection another job's agents have. """ return uuid.uuid4().hex[: run_id_len()] @@ -307,7 +346,7 @@ async def provision_agent( (default ``E2E baseline test agent ({label})``). Tests that assert on passive-roster description surfacing pass a self-sourced marker here. """ - name = agent_name(run_id=self._run_id, label=label) + name = agent_name(run_id=name_token(label), label=label) agent_description = description or f"E2E baseline test agent ({label})" response = await self._client.human_api_agents.register_my_agent( agent=AgentRegisterRequest( @@ -413,13 +452,30 @@ async def reap_all(self) -> None: except Exception: logger.warning("Failed to reap agent %s", agent_id, exc_info=True) + @asynccontextmanager + async def reap_on_exit(self, *, enabled: bool = True) -> AsyncGenerator[None, None]: + """Reap everything provisioned in this block when it exits. + + ``enabled`` is false when ``BAND_E2E_AUTOCLEAN`` is off, so a debug run + keeps the resources. ``reap_all`` logs a delete failure and continues, so + a teardown error does not replace an exception from the block. A row + whose id was never returned is not tracked and is not deleted here; the + next provision draws a new name, so that leftover cannot collide. + """ + try: + yield + finally: + if enabled: + await self.reap_all() + async def sweep_orphans(self) -> int: """Force-delete stale test agents left by crashed prior runs. - Only touches agents whose name carries ``NAME_PREFIX``, belongs to a - *different* run, and is older than ``orphan_max_age_minutes`` — so a - concurrent run on the same shared platform is never deleted mid-flight. - Returns the number of agents reaped. + Only touches agents whose name carries ``NAME_PREFIX`` and that are + older than ``orphan_max_age_minutes``. Names that still embed this + session's run id are skipped. Per-provision names do not embed it; a + concurrent run's fresh agents stay because they are younger than the + age gate. Returns the number of agents reaped. """ max_age = timedelta(minutes=self._settings.run.orphan_max_age_minutes) cutoff = datetime.now(UTC) - max_age @@ -439,7 +495,7 @@ async def sweep_orphans(self) -> int: if not agent.name.startswith(NAME_PREFIX): continue # name filter is a contains-match; re-check the prefix if f"-{self._run_id}-" in agent.name: - continue # never reap our own run + continue # inserted_at may be tz-naive depending on serialization; treat # naive as UTC so the comparison never raises (see the codebase's # _coerce_inserted_at). A naive>aware compare would TypeError and @@ -618,9 +674,9 @@ def build( async def provision(self, *, label: str | None = None) -> ProvisionedAgent: """Register an identity for this cell (tracked + reaped by the manager); no run. - ``label`` defaults to the adapter id (a readable provisioned name). Pass a - distinct label to register more than one identity of the same cell in a single - test, else the generated names collide. + ``label`` defaults to the adapter id (a readable provisioned name). Each + call draws its own name token, so the same label can be registered again + in one test without colliding with an agent this run already created. """ provisioned = await self.resources.provision_agent(label or self.adapter_id) return replace(provisioned, adapter_id=self.adapter_id) @@ -708,7 +764,7 @@ async def run_many( what a same-adapter co-residency gate must probe. ``labels`` default to ``{adapter_id}-{index}``; an explicit list must be length - ``count`` so the provisioned names don't collide. ``prompt`` / ``features`` / + ``count`` (one label per member). ``prompt`` / ``features`` / ``tools`` pass through to each :meth:`run_as`, preserving the cell defaults when omitted. """ diff --git a/tests/framework_conformance/test_provision_names.py b/tests/framework_conformance/test_provision_names.py new file mode 100644 index 000000000..ec927c95e --- /dev/null +++ b/tests/framework_conformance/test_provision_names.py @@ -0,0 +1,95 @@ +"""Each provision registers under its own agent name. + +A leftover row for a label must not make the next provision of that label +ask the platform for the same name. +""" + +from __future__ import annotations + +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +import pytest +from band_rest import AgentRegisterRequest + +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.toolkit.provisioning import ( + MENTION_HANDLE_CAP, + NAME_PREFIX, + ResourceManager, + name_token, +) + + +def recording_manager() -> tuple[ResourceManager, AsyncMock]: + def respond(*, agent: AgentRegisterRequest) -> MagicMock: + response = MagicMock() + response.data.agent.id = agent.name + response.data.credentials.api_key = "test-key" + return response + + register = AsyncMock(side_effect=respond) + client = MagicMock() + client.human_api_agents.register_my_agent = register + manager = ResourceManager( + user_client=client, + settings=cast(BaselineSettings, MagicMock()), + run_id="session", + ) + return manager, register + + +def registered_names(register: AsyncMock) -> list[str]: + return [call.kwargs["agent"].name for call in register.await_args_list] + + +def name_parts(name: str) -> tuple[str, str]: + """Hex token and label from a provisioned name. The token contains no hyphen.""" + token, _separator, label = name.removeprefix(NAME_PREFIX).partition("-") + return token, label + + +def is_hex(token: str) -> bool: + return bool(token) and all(character in "0123456789abcdef" for character in token) + + +class TestProvisionNames: + async def test_same_mentioned_label_registers_two_names_inside_the_cap( + self, + ) -> None: + manager, register = recording_manager() + + first = await manager.provision_agent("invitable") + second = await manager.provision_agent("invitable") + + names = registered_names(register) + assert names == [first.name, second.name] + assert len(set(names)) == 2 + for name in names: + token, label = name_parts(name) + assert name.startswith(NAME_PREFIX) + assert label == "invitable" + assert is_hex(token) + assert len(name) == MENTION_HANDLE_CAP + + async def test_adapter_label_uses_a_wide_token_within_the_name_limit( + self, + ) -> None: + manager, register = recording_manager() + + first = await manager.provision_agent("copilot_sdk") + second = await manager.provision_agent("copilot_sdk") + + names = registered_names(register) + assert names == [first.name, second.name] + assert len(set(names)) == 2 + for name in names: + token, label = name_parts(name) + assert label == "copilot-sdk" + assert is_hex(token) + assert len(token) == 32 + assert len(name) <= 100 + + def test_label_too_long_for_a_unique_name_is_rejected(self) -> None: + with pytest.raises(ValueError, match="unique name token"): + name_token("x" * 90) diff --git a/tests/framework_conformance/test_reap_on_exit.py b/tests/framework_conformance/test_reap_on_exit.py new file mode 100644 index 000000000..f8b9a870c --- /dev/null +++ b/tests/framework_conformance/test_reap_on_exit.py @@ -0,0 +1,63 @@ +"""Test-end cleanup reaps tracked resources without hiding the test error.""" + +from __future__ import annotations + +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.toolkit.provisioning import ResourceManager + +REGISTERED_AGENT_ID = "agent-1" + + +def manager() -> tuple[ResourceManager, AsyncMock]: + def respond(**_kwargs: object) -> MagicMock: + response = MagicMock() + response.data.agent.id = REGISTERED_AGENT_ID + response.data.credentials.api_key = "test-key" + return response + + client = MagicMock() + client.human_api_agents.register_my_agent = AsyncMock(side_effect=respond) + delete = AsyncMock() + client.human_api_agents.delete_my_agent = delete + return ( + ResourceManager( + user_client=client, + settings=cast(BaselineSettings, MagicMock()), + run_id="session", + ), + delete, + ) + + +class TestReapOnExit: + async def test_reaps_on_normal_exit(self) -> None: + resources, delete = manager() + + async with resources.reap_on_exit(): + await resources.provision_agent("copilot_sdk") + + delete.assert_awaited_once_with(REGISTERED_AGENT_ID, force=True) + + async def test_body_error_survives_a_failed_reap(self) -> None: + resources, delete = manager() + delete.side_effect = RuntimeError("delete failed") + + with pytest.raises(RuntimeError, match="boom"): + async with resources.reap_on_exit(): + await resources.provision_agent("copilot_sdk") + raise RuntimeError("boom") + + delete.assert_awaited_once_with(REGISTERED_AGENT_ID, force=True) + + async def test_disabled_does_not_reap(self) -> None: + resources, delete = manager() + + async with resources.reap_on_exit(enabled=False): + await resources.provision_agent("copilot_sdk") + + delete.assert_not_awaited() From 85b2294f6dd27026716de3a3504c9f5a8c609bcf Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 15 Oct 2026 06:55:29 +0300 Subject: [PATCH 2/5] refactor: name the provision token for what it is Mentionable labels take a fixed short token. Only longer labels take a uuid slice. Co-authored-by: Cursor --- tests/e2e/baseline/toolkit/provisioning.py | 29 +++++++++---------- .../test_provision_names.py | 8 +++++ .../test_toolkit_helpers.py | 4 +-- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/tests/e2e/baseline/toolkit/provisioning.py b/tests/e2e/baseline/toolkit/provisioning.py index adb5549e5..f320c9a6a 100644 --- a/tests/e2e/baseline/toolkit/provisioning.py +++ b/tests/e2e/baseline/toolkit/provisioning.py @@ -88,32 +88,31 @@ def normalized_label(label: str) -> str: return label.replace("_", "-") -def agent_name(*, run_id: str, label: str) -> str: - """The provisioned name for ``label`` and name token ``run_id``. +def agent_name(*, token: str, label: str) -> str: + """The provisioned name for ``label`` and ``token``. Underscores become hyphens: models rewrite ``omp_acp`` as ``omp-acp`` when repeating a name, so an adapter-id label must already be in the form they echo. """ - return f"{NAME_PREFIX}{run_id}-{normalized_label(label)}" + return f"{NAME_PREFIX}{token}-{normalized_label(label)}" def name_token(label: str) -> str: """A fresh hex token for one provision of ``label``. - A peer a scenario @mentions only surfaces its full name inside - ``MENTION_HANDLE_CAP``, so that token uses whatever width the cap still - leaves. Adapter-id labels already pass the cap and take a full uuid slice: - every cell of one adapter would otherwise register the same name, and a - row left behind makes the next cell's register fail with name-taken. + A label a scenario can @mention gets :func:`run_id_len` characters, so the + longest such name stays inside ``MENTION_HANDLE_CAP``. A longer label is not + a mention handle and gets a full uuid slice, clamped so the name stays + inside ``AGENT_NAME_MAX``. Replacing the shared session id is what keeps a + leftover row from making the next provision of the same label fail name-taken. """ normalized = normalized_label(label) if len(normalized) <= MAX_MENTIONED_LABEL_LEN: - width = MENTION_HANDLE_CAP - len(NAME_PREFIX) - 1 - len(normalized) - else: - width = min( - NAME_TOKEN_HEX, - AGENT_NAME_MAX - len(NAME_PREFIX) - 1 - len(normalized), - ) + return uuid.uuid4().hex[: run_id_len()] + width = min( + NAME_TOKEN_HEX, + AGENT_NAME_MAX - len(NAME_PREFIX) - 1 - len(normalized), + ) if width < run_id_len(): raise ValueError( f"label {normalized!r} leaves {width} characters for a unique name " @@ -346,7 +345,7 @@ async def provision_agent( (default ``E2E baseline test agent ({label})``). Tests that assert on passive-roster description surfacing pass a self-sourced marker here. """ - name = agent_name(run_id=name_token(label), label=label) + name = agent_name(token=name_token(label), label=label) agent_description = description or f"E2E baseline test agent ({label})" response = await self._client.human_api_agents.register_my_agent( agent=AgentRegisterRequest( diff --git a/tests/framework_conformance/test_provision_names.py b/tests/framework_conformance/test_provision_names.py index ec927c95e..fde362242 100644 --- a/tests/framework_conformance/test_provision_names.py +++ b/tests/framework_conformance/test_provision_names.py @@ -18,6 +18,7 @@ NAME_PREFIX, ResourceManager, name_token, + run_id_len, ) @@ -70,8 +71,15 @@ async def test_same_mentioned_label_registers_two_names_inside_the_cap( assert name.startswith(NAME_PREFIX) assert label == "invitable" assert is_hex(token) + assert len(token) == run_id_len() assert len(name) == MENTION_HANDLE_CAP + short = await manager.provision_agent("echo") + short_token, short_label = name_parts(short.name) + assert short_label == "echo" + assert len(short_token) == run_id_len() + assert len(short.name) <= MENTION_HANDLE_CAP + async def test_adapter_label_uses_a_wide_token_within_the_name_limit( self, ) -> None: diff --git a/tests/framework_conformance/test_toolkit_helpers.py b/tests/framework_conformance/test_toolkit_helpers.py index c411b790b..4ddfbf826 100644 --- a/tests/framework_conformance/test_toolkit_helpers.py +++ b/tests/framework_conformance/test_toolkit_helpers.py @@ -292,7 +292,7 @@ async def test_adopt_room_is_reaped_once_on_teardown() -> None: def test_adapter_id_labels_become_names_models_echo_verbatim() -> None: """A model repeating ``omp_acp`` writes ``omp-acp``; the name must already match.""" - assert agent_name(run_id="ab12c", label="omp_acp") == "e2e-band-ab12c-omp-acp" + assert agent_name(token="ab12c", label="omp_acp") == "e2e-band-ab12c-omp-acp" def test_mentioned_peer_names_fit_handle_cap() -> None: @@ -315,7 +315,7 @@ def test_mentioned_peer_names_fit_handle_cap() -> None: # The longest label a scenario provisions AND asserts by @mention must fit. longest = "x" * MAX_MENTIONED_LABEL_LEN - name = agent_name(run_id="a" * length, label=longest) + name = agent_name(token="a" * length, label=longest) assert len(name) <= MENTION_HANDLE_CAP, ( f"longest @mentioned peer name {name!r} is {len(name)} chars, over the " f"{MENTION_HANDLE_CAP}-char mention-handle cap" From bddd1dcf36735fd3de9c2ac96a44f10c551a6d41 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 15 Oct 2026 07:25:58 +0300 Subject: [PATCH 3/5] fix: keep a recorded agent out of the orphan sweep Per-provision names do not contain the session id, so an aged staging agent was swept and the follow-up delete blocked replacement. The name budget shares one separator. Co-authored-by: Cursor --- examples/sandbox/staging-smoke/probe.py | 11 ++-- tests/e2e/baseline/toolkit/provisioning.py | 36 ++++++++---- .../test_provision_names.py | 26 ++++++++- .../framework_conformance/test_sweep_owned.py | 58 +++++++++++++++++++ 4 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 tests/framework_conformance/test_sweep_owned.py diff --git a/examples/sandbox/staging-smoke/probe.py b/examples/sandbox/staging-smoke/probe.py index 3fc0f7180..7e281d39f 100644 --- a/examples/sandbox/staging-smoke/probe.py +++ b/examples/sandbox/staging-smoke/probe.py @@ -184,10 +184,13 @@ async def provision( ) # Reap leftovers this workflow itself may have abandoned (a crashed prior - # run's agent is invisible to any later run once its state file rotates) — - # the toolkit's sweep is prefix- and age-guarded, so nothing else's - # resources are ever touched. - await manager.sweep_orphans() + # run's agent is invisible to any later run once its state file rotates). + # The sweep is prefix- and age-guarded. This run's recorded agent is kept + # by id: its name does not embed the smoke run id, and a resume can outlast + # the age gate. + await manager.sweep_orphans( + keep_ids=[run_state.agent_id] if run_state.agent_id else [] + ) # Re-provisioning within a run (daemon-restart recovery, a crashed # launch): the recorded agent/room belong to a dead process — replace # them. Refuse to stack new resources on top of ones that failed to reap diff --git a/tests/e2e/baseline/toolkit/provisioning.py b/tests/e2e/baseline/toolkit/provisioning.py index f320c9a6a..4e0dfcfae 100644 --- a/tests/e2e/baseline/toolkit/provisioning.py +++ b/tests/e2e/baseline/toolkit/provisioning.py @@ -53,6 +53,9 @@ # All provisioned agent names start with this; the orphan sweep matches on it. NAME_PREFIX = "e2e-band-" +# Between the token and the label. Both name budgets subtract this same separator. +TOKEN_LABEL_SEPARATOR = "-" + # The platform truncates @mention handles beyond this many characters. A peer that a # scenario provisions and later asserts by @mention (a roster reply names it as # ``@handle``) only surfaces its full name if that name fits the cap; beyond it the @@ -78,10 +81,15 @@ NAME_TOKEN_HEX = 32 +def name_budget(limit: int, label_len: int) -> int: + """Characters left for the token once the prefix, separator, and label are counted.""" + return limit - len(NAME_PREFIX) - len(TOKEN_LABEL_SEPARATOR) - label_len + + def run_id_len() -> int: """Hex length for a run id that keeps the longest @mentioned peer name within - the handle cap: ``CAP - len(prefix) - len("-") - longest mentioned label``.""" - return MENTION_HANDLE_CAP - len(NAME_PREFIX) - 1 - MAX_MENTIONED_LABEL_LEN + the handle cap.""" + return name_budget(MENTION_HANDLE_CAP, MAX_MENTIONED_LABEL_LEN) def normalized_label(label: str) -> str: @@ -94,7 +102,7 @@ def agent_name(*, token: str, label: str) -> str: Underscores become hyphens: models rewrite ``omp_acp`` as ``omp-acp`` when repeating a name, so an adapter-id label must already be in the form they echo. """ - return f"{NAME_PREFIX}{token}-{normalized_label(label)}" + return f"{NAME_PREFIX}{token}{TOKEN_LABEL_SEPARATOR}{normalized_label(label)}" def name_token(label: str) -> str: @@ -109,10 +117,7 @@ def name_token(label: str) -> str: normalized = normalized_label(label) if len(normalized) <= MAX_MENTIONED_LABEL_LEN: return uuid.uuid4().hex[: run_id_len()] - width = min( - NAME_TOKEN_HEX, - AGENT_NAME_MAX - len(NAME_PREFIX) - 1 - len(normalized), - ) + width = min(NAME_TOKEN_HEX, name_budget(AGENT_NAME_MAX, len(normalized))) if width < run_id_len(): raise ValueError( f"label {normalized!r} leaves {width} characters for a unique name " @@ -467,14 +472,16 @@ async def reap_on_exit(self, *, enabled: bool = True) -> AsyncGenerator[None, No if enabled: await self.reap_all() - async def sweep_orphans(self) -> int: + async def sweep_orphans(self, *, keep_ids: Sequence[str] = ()) -> int: """Force-delete stale test agents left by crashed prior runs. Only touches agents whose name carries ``NAME_PREFIX`` and that are older than ``orphan_max_age_minutes``. Names that still embed this - session's run id are skipped. Per-provision names do not embed it; a - concurrent run's fresh agents stay because they are younger than the - age gate. Returns the number of agents reaped. + session's run id are skipped, and so are ``keep_ids``: a per-provision + name does not embed that id, so a caller that still owns an agent (the + staging probe's recorded one, across a resume longer than the age gate) + passes its id here. A concurrent run's fresh agents stay because they + are younger than the age gate. Returns the number of agents reaped. """ max_age = timedelta(minutes=self._settings.run.orphan_max_age_minutes) cutoff = datetime.now(UTC) - max_age @@ -484,6 +491,7 @@ async def sweep_orphans(self) -> int: # Cursor pagination (the SDK's preferred path; offset `page`/`page_size` # is deprecated): advance via metadata.next_cursor until has_more is # false. The iteration cap bounds a best-effort sweep. + owned = set(keep_ids) orphans: list[str] = [] cursor: str | None = None for _ in range(20): @@ -493,7 +501,11 @@ async def sweep_orphans(self) -> int: for agent in response.data or []: if not agent.name.startswith(NAME_PREFIX): continue # name filter is a contains-match; re-check the prefix - if f"-{self._run_id}-" in agent.name: + if ( + agent.id in owned + or f"{TOKEN_LABEL_SEPARATOR}{self._run_id}{TOKEN_LABEL_SEPARATOR}" + in agent.name + ): continue # inserted_at may be tz-naive depending on serialization; treat # naive as UTC so the comparison never raises (see the codebase's diff --git a/tests/framework_conformance/test_provision_names.py b/tests/framework_conformance/test_provision_names.py index fde362242..635294699 100644 --- a/tests/framework_conformance/test_provision_names.py +++ b/tests/framework_conformance/test_provision_names.py @@ -14,18 +14,23 @@ from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.provisioning import ( + AGENT_NAME_MAX, MENTION_HANDLE_CAP, NAME_PREFIX, + NAME_TOKEN_HEX, ResourceManager, + name_budget, name_token, run_id_len, ) +REGISTERED_AGENT_ID = "registered-agent" + def recording_manager() -> tuple[ResourceManager, AsyncMock]: def respond(*, agent: AgentRegisterRequest) -> MagicMock: response = MagicMock() - response.data.agent.id = agent.name + response.data.agent.id = REGISTERED_AGENT_ID response.data.credentials.api_key = "test-key" return response @@ -65,6 +70,8 @@ async def test_same_mentioned_label_registers_two_names_inside_the_cap( names = registered_names(register) assert names == [first.name, second.name] + assert first.id == REGISTERED_AGENT_ID + assert first.name != first.id assert len(set(names)) == 2 for name in names: token, label = name_parts(name) @@ -90,13 +97,28 @@ async def test_adapter_label_uses_a_wide_token_within_the_name_limit( names = registered_names(register) assert names == [first.name, second.name] + assert first.id == REGISTERED_AGENT_ID + assert first.name != first.id assert len(set(names)) == 2 for name in names: token, label = name_parts(name) assert label == "copilot-sdk" assert is_hex(token) assert len(token) == 32 - assert len(name) <= 100 + assert len(name) <= AGENT_NAME_MAX + + async def test_long_label_clamps_the_token_to_the_name_limit(self) -> None: + manager, register = recording_manager() + # One shorter than a full uuid, so the token has to shrink to fit. + label = "x" * (name_budget(AGENT_NAME_MAX, 0) - (NAME_TOKEN_HEX - 1)) + + await manager.provision_agent(label) + + token, parsed = name_parts(registered_names(register)[0]) + assert parsed == label + assert len(token) < NAME_TOKEN_HEX + assert len(token) >= run_id_len() + assert len(registered_names(register)[0]) == AGENT_NAME_MAX def test_label_too_long_for_a_unique_name_is_rejected(self) -> None: with pytest.raises(ValueError, match="unique name token"): diff --git a/tests/framework_conformance/test_sweep_owned.py b/tests/framework_conformance/test_sweep_owned.py new file mode 100644 index 000000000..41bba9797 --- /dev/null +++ b/tests/framework_conformance/test_sweep_owned.py @@ -0,0 +1,58 @@ +"""An agent this run still owns is not an orphan, even after the age gate.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.toolkit.provisioning import ( + ResourceManager, + agent_name, +) + +OWNED_AGENT_ID = "owned-1" +SESSION_RUN_ID = "78228ce0" + + +def listing_manager() -> tuple[ResourceManager, MagicMock, AsyncMock]: + agent = MagicMock() + agent.id = OWNED_AGENT_ID + agent.name = agent_name(token="abcde", label="sandbox") + agent.inserted_at = datetime.now(UTC) - timedelta(minutes=121) + listed = MagicMock() + listed.data = [agent] + listed.metadata.next_cursor = None + listed.metadata.has_more = False + settings = MagicMock() + settings.run.orphan_max_age_minutes = 120 + client = MagicMock() + client.human_api_agents.list_my_agents = AsyncMock(return_value=listed) + delete = AsyncMock() + client.human_api_agents.delete_my_agent = delete + manager = ResourceManager( + user_client=client, + settings=cast(BaselineSettings, settings), + run_id=SESSION_RUN_ID, + ) + return manager, agent, delete + + +class TestSweepOwned: + async def test_aged_owned_agent_is_not_swept(self) -> None: + manager, agent, delete = listing_manager() + assert f"-{SESSION_RUN_ID}-" not in agent.name + + reaped = await manager.sweep_orphans(keep_ids=[agent.id]) + + assert reaped == 0 + delete.assert_not_awaited() + + async def test_aged_agent_without_an_owner_is_swept(self) -> None: + manager, agent, delete = listing_manager() + + reaped = await manager.sweep_orphans() + + assert reaped == 1 + delete.assert_awaited_once_with(agent.id, force=True) From 0077fd0ec823908634848fe40dca60853c690e44 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 15 Oct 2026 07:53:05 +0300 Subject: [PATCH 4/5] docs: describe the session id as the sweep key Provisions draw their own name token. The session id is only what the orphan sweep skips. Co-authored-by: Cursor --- tests/e2e/baseline/fixtures/platform.py | 2 +- tests/e2e/baseline/toolkit/provisioning.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/e2e/baseline/fixtures/platform.py b/tests/e2e/baseline/fixtures/platform.py index 3f8d1493a..fd7686446 100644 --- a/tests/e2e/baseline/fixtures/platform.py +++ b/tests/e2e/baseline/fixtures/platform.py @@ -50,7 +50,7 @@ def baseline_settings() -> BaselineSettings: @pytest.fixture(scope="session") def baseline_run_id() -> str: - """Token identifying this session's provisioned resources (for naming/sweep).""" + """Token for this session's orphan-sweep self-skip.""" return new_run_id() diff --git a/tests/e2e/baseline/toolkit/provisioning.py b/tests/e2e/baseline/toolkit/provisioning.py index 4e0dfcfae..1fadd75c5 100644 --- a/tests/e2e/baseline/toolkit/provisioning.py +++ b/tests/e2e/baseline/toolkit/provisioning.py @@ -87,8 +87,11 @@ def name_budget(limit: int, label_len: int) -> int: def run_id_len() -> int: - """Hex length for a run id that keeps the longest @mentioned peer name within - the handle cap.""" + """Hex length of a mentionable provision token. + + The longest label a scenario can @mention stays inside the handle cap at + this width. :func:`name_token` uses it for those labels. + """ return name_budget(MENTION_HANDLE_CAP, MAX_MENTIONED_LABEL_LEN) @@ -111,8 +114,8 @@ def name_token(label: str) -> str: A label a scenario can @mention gets :func:`run_id_len` characters, so the longest such name stays inside ``MENTION_HANDLE_CAP``. A longer label is not a mention handle and gets a full uuid slice, clamped so the name stays - inside ``AGENT_NAME_MAX``. Replacing the shared session id is what keeps a - leftover row from making the next provision of the same label fail name-taken. + inside ``AGENT_NAME_MAX``. A fresh token per provision is what keeps a + leftover row from colliding with the next register of the same label. """ normalized = normalized_label(label) if len(normalized) <= MAX_MENTIONED_LABEL_LEN: @@ -130,11 +133,9 @@ def name_token(label: str) -> str: def new_run_id() -> str: """Short token the session fixture draws once for the orphan sweep's self-skip. - Its length is derived from the mention-handle cap (see :func:`run_id_len`) so - a name that still embeds this token fits the longest @mentioned peer. Each - provision draws its own token via :func:`name_token` rather than reusing this - one. The sweep still skips names containing this session's token; per-provision - names rely on the age gate, the same protection another job's agents have. + Provisions do not embed it; they draw :func:`name_token`. The sweep skips + names that still contain this token. Per-provision names rely on the age + gate, the same protection another job's agents have. """ return uuid.uuid4().hex[: run_id_len()] From f373ff7ab763770eac1ca4ab498947ad190046db Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 15 Oct 2026 17:24:18 +0300 Subject: [PATCH 5/5] fix: keep provisioned names inside the API-key cap Registration creates the agent and an API key in one transaction, and the key name is the agent name plus a suffix that is also capped at 100. Budgeting only the agent row lets a long label roll the register back. Co-authored-by: Cursor --- tests/e2e/baseline/toolkit/provisioning.py | 35 ++++++++++++++++--- .../test_provision_names.py | 21 +++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/tests/e2e/baseline/toolkit/provisioning.py b/tests/e2e/baseline/toolkit/provisioning.py index 1fadd75c5..39d1e05ab 100644 --- a/tests/e2e/baseline/toolkit/provisioning.py +++ b/tests/e2e/baseline/toolkit/provisioning.py @@ -72,10 +72,17 @@ # silently truncating a handle. MAX_MENTIONED_LABEL_LEN = len("invitable") # 9 -# Platform ``validate_length(:name, max: 100)``. A longer register is rejected -# for length, which is a different 422 from name-taken. +# Platform ``validate_length(:name, max: 100)`` on the agent row. A longer +# register is rejected for length, which is a different 422 from name-taken. AGENT_NAME_MAX = 100 +# Same cap on the API key created in that register. The key name is longer +# than the agent name; see :func:`credential_name`. +API_KEY_NAME_MAX = 100 + +# Leading characters of the agent id appended to that key's name. +CREDENTIAL_ID_SLICE = 8 + # ``uuid4().hex`` width. Labels that may exceed the mention cap take the whole # uuid so a leftover row cannot collide with the next provision of that label. NAME_TOKEN_HEX = 32 @@ -108,24 +115,42 @@ def agent_name(*, token: str, label: str) -> str: return f"{NAME_PREFIX}{token}{TOKEN_LABEL_SEPARATOR}{normalized_label(label)}" +def credential_name(name: str, agent_id: str) -> str: + """Name the platform stores on the API key created with the agent. + + The agent row and the key are one transaction. The key name has its own + ``API_KEY_NAME_MAX`` cap, so an agent name inside ``AGENT_NAME_MAX`` still + rolls the registration back when this string does not fit. + """ + return f"{name} ({agent_id[:CREDENTIAL_ID_SLICE]})" + + +# Longest agent name whose key name still fits. Measured with an id of +# ``CREDENTIAL_ID_SLICE`` characters; every registered id is at least that long. +REGISTRATION_NAME_MAX = min( + AGENT_NAME_MAX, + API_KEY_NAME_MAX - len(credential_name("", "0" * CREDENTIAL_ID_SLICE)), +) + + def name_token(label: str) -> str: """A fresh hex token for one provision of ``label``. A label a scenario can @mention gets :func:`run_id_len` characters, so the longest such name stays inside ``MENTION_HANDLE_CAP``. A longer label is not a mention handle and gets a full uuid slice, clamped so the name stays - inside ``AGENT_NAME_MAX``. A fresh token per provision is what keeps a + inside ``REGISTRATION_NAME_MAX``. A fresh token per provision is what keeps a leftover row from colliding with the next register of the same label. """ normalized = normalized_label(label) if len(normalized) <= MAX_MENTIONED_LABEL_LEN: return uuid.uuid4().hex[: run_id_len()] - width = min(NAME_TOKEN_HEX, name_budget(AGENT_NAME_MAX, len(normalized))) + width = min(NAME_TOKEN_HEX, name_budget(REGISTRATION_NAME_MAX, len(normalized))) if width < run_id_len(): raise ValueError( f"label {normalized!r} leaves {width} characters for a unique name " f"token; need at least {run_id_len()} within the " - f"{AGENT_NAME_MAX}-character name limit" + f"{REGISTRATION_NAME_MAX}-character registration name limit" ) return uuid.uuid4().hex[:width] diff --git a/tests/framework_conformance/test_provision_names.py b/tests/framework_conformance/test_provision_names.py index 635294699..2d2fc1b94 100644 --- a/tests/framework_conformance/test_provision_names.py +++ b/tests/framework_conformance/test_provision_names.py @@ -15,10 +15,13 @@ from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.provisioning import ( AGENT_NAME_MAX, + API_KEY_NAME_MAX, MENTION_HANDLE_CAP, NAME_PREFIX, NAME_TOKEN_HEX, + REGISTRATION_NAME_MAX, ResourceManager, + credential_name, name_budget, name_token, run_id_len, @@ -105,21 +108,25 @@ async def test_adapter_label_uses_a_wide_token_within_the_name_limit( assert label == "copilot-sdk" assert is_hex(token) assert len(token) == 32 - assert len(name) <= AGENT_NAME_MAX + assert len(name) <= REGISTRATION_NAME_MAX - async def test_long_label_clamps_the_token_to_the_name_limit(self) -> None: + async def test_long_label_clamps_so_the_key_name_fits(self) -> None: manager, register = recording_manager() # One shorter than a full uuid, so the token has to shrink to fit. - label = "x" * (name_budget(AGENT_NAME_MAX, 0) - (NAME_TOKEN_HEX - 1)) + label = "x" * (name_budget(REGISTRATION_NAME_MAX, 0) - (NAME_TOKEN_HEX - 1)) await manager.provision_agent(label) - token, parsed = name_parts(registered_names(register)[0]) + name = registered_names(register)[0] + token, parsed = name_parts(name) assert parsed == label assert len(token) < NAME_TOKEN_HEX assert len(token) >= run_id_len() - assert len(registered_names(register)[0]) == AGENT_NAME_MAX + assert len(credential_name(name, REGISTERED_AGENT_ID)) == API_KEY_NAME_MAX - def test_label_too_long_for_a_unique_name_is_rejected(self) -> None: + def test_label_that_fits_the_agent_row_but_not_the_key_is_rejected(self) -> None: + # Longest label the agent-row cap still gives a token. The key name + # does not fit, so the label is rejected instead of registered. + label_len = name_budget(AGENT_NAME_MAX, 0) - run_id_len() with pytest.raises(ValueError, match="unique name token"): - name_token("x" * 90) + name_token("x" * label_len)