cf67ba0a86
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 37s
CI / unit_tests (pull_request) Successful in 2m27s
CI / docker (pull_request) Successful in 49s
CI / integration_tests (pull_request) Successful in 3m46s
CI / coverage (pull_request) Successful in 4m56s
CI / lint (push) Successful in 12s
CI / build (push) Successful in 14s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 4m7s
CI / integration_tests (push) Successful in 4m41s
CI / docker (push) Successful in 44s
CI / coverage (push) Successful in 4m56s
CI / benchmark-publish (push) Successful in 17m17s
CI / benchmark-regression (pull_request) Successful in 31m25s
Implemented lazy container activation for devcontainer-instance resources with ContainerLifecycleState enum tracking six states (inactive, starting, active, stopping, stopped, error) with validated transitions. Extended DevcontainerHandler with devcontainer up CLI integration and JSON output parsing for container start. Added periodic health checking via devcontainer exec ping with configurable interval. Added agents resource stop and agents resource rebuild CLI commands for manual lifecycle control. Wired session close and plan completion hooks to automatic container cleanup. Includes lifecycle state persistence in resource registry with timestamped transitions. Added Behave BDD tests, Robot integration tests, and ASV activation latency benchmarks. - Added remoteWorkspaceFolder absolute-path validation - Aligned spec: handler name, rebuild types, --yes flag on stop/rebuild - Added registry re-read in stop_container success path for consistency - Added session_id field to ContainerLifecycleTracker for scoped cleanup - Scoped stop_all_active_containers to session_id when provided - Wired _cleanup_devcontainers into fail_apply and fail_execute - Wired start_health_check into activate_container success path - Restructured facade session close to always run container cleanup even without session service (F4) - Re-read tracker from registry in activate_container success path - Added evict_terminal_trackers to cap registry growth - Updated devcontainer_resources.md: health check auto-start, scoped cleanup hooks, known limitations for eviction and sandbox_strategy - Wired evict_terminal_trackers into stop_all_active_containers so terminal-state trackers are actually evicted in production - Made stop_container idempotent: returns early when container is already in a terminal state instead of raising ValueError - Fixed benchmark health check thread leak in TimeActivationLatency by clearing registry after each timing loop - Added rebuild pass-through (--reset-container flag to devcontainer up) - Added host_workspace_path field on ContainerLifecycleTracker so health probes use the host-side path for devcontainer exec - Wired lazy activation into DevcontainerHandler.resolve() for devcontainer-instance resources in non-running states - Changed _default_strategy from SNAPSHOT to NONE (container itself provides isolation; SandboxFactory raises NotImplementedError for snapshot) - Restricted _STOPPABLE_TYPES to devcontainer-instance only (container-instance is not directly stoppable via CLI) ISSUES CLOSED: #514
465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""Step definitions for devcontainer lifecycle state model, registry, and JSON parsing.
|
|
|
|
Covers: lifecycle state enum, state transitions, tracker creation and
|
|
retrieval, registry operations (get/set/clear/evict), argument
|
|
validation for validate_transition and transition_state, the
|
|
transition history cap, and devcontainer-up JSON output parsing.
|
|
|
|
All mocks are in ``features/mocks/mock_devcontainer_cli.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import cast
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.core.container_lifecycle import (
|
|
ContainerLifecycleState,
|
|
ContainerLifecycleTracker,
|
|
transition_state,
|
|
validate_transition,
|
|
)
|
|
from cleveragents.resource.handlers.devcontainer import (
|
|
_lifecycle_registry,
|
|
_parse_devcontainer_up_output,
|
|
_registry_lock,
|
|
clear_lifecycle_registry,
|
|
evict_terminal_trackers,
|
|
get_lifecycle_tracker,
|
|
list_active_containers,
|
|
set_lifecycle_tracker,
|
|
)
|
|
|
|
# ── Shared helper ────────────────────────────────────────────
|
|
|
|
|
|
# R21 fix: shared _OneShotEvent helper — extracted from duplicated
|
|
# definitions in step_single_health_probe and step_run_health_check_loop_once.
|
|
#
|
|
# F8 fix: documented the exact call sequence the counter relies on.
|
|
class _OneShotEvent:
|
|
"""Stop event that allows exactly one probe iteration.
|
|
|
|
Simulates ``threading.Event`` for health check loop testing,
|
|
running exactly one probe cycle before signaling stop.
|
|
|
|
The counter relies on the following call sequence inside
|
|
``_health_check_loop``:
|
|
|
|
1. ``while not stop_event.is_set()`` → count=1, returns False
|
|
2. ``stop_event.wait(interval)`` → overridden to no-op
|
|
3. ``if stop_event.is_set()`` → count=2, returns False
|
|
4. (probe executes)
|
|
5. ``while not stop_event.is_set()`` → count=3, returns True → exit
|
|
|
|
**WARNING**: If the loop's ``is_set()`` call pattern changes, this
|
|
counter threshold must be updated to match.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._count = 0
|
|
|
|
def is_set(self) -> bool:
|
|
self._count += 1
|
|
return self._count > 2
|
|
|
|
def set(self) -> None:
|
|
self._count = 999
|
|
|
|
def wait(self, timeout: float = 0) -> bool:
|
|
return False
|
|
|
|
|
|
# ── Given steps ──────────────────────────────────────────────
|
|
# Registry cleanup is handled by the before_scenario hook in
|
|
# features/environment.py so that all scenarios start with a
|
|
# clean lifecycle registry regardless of execution order.
|
|
|
|
|
|
@given('a lifecycle tracker for resource "{resource_id}"')
|
|
def step_create_tracker(context: Context, resource_id: str) -> None:
|
|
tracker = ContainerLifecycleTracker(resource_id=resource_id)
|
|
set_lifecycle_tracker(tracker)
|
|
context.tracker = tracker
|
|
context.resource_id = resource_id
|
|
|
|
|
|
@given('a lifecycle tracker in "{state}" state for resource "{resource_id}"')
|
|
def step_create_tracker_in_state(
|
|
context: Context, state: str, resource_id: str
|
|
) -> None:
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id=resource_id,
|
|
current_state=ContainerLifecycleState(state),
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
context.tracker = tracker
|
|
context.resource_id = resource_id
|
|
|
|
|
|
@given("{count:d} stopped container trackers in the registry")
|
|
def step_create_many_stopped(context: Context, count: int) -> None:
|
|
with _registry_lock:
|
|
for i in range(count):
|
|
rid = f"01EVICT{i:020d}"
|
|
_lifecycle_registry[rid] = ContainerLifecycleTracker(
|
|
resource_id=rid,
|
|
current_state=ContainerLifecycleState.STOPPED,
|
|
)
|
|
|
|
|
|
# ── When steps ───────────────────────────────────────────────
|
|
|
|
|
|
@when("I inspect ContainerLifecycleState enum values")
|
|
def step_inspect_enum(context: Context) -> None:
|
|
context.enum_values = [s.value for s in ContainerLifecycleState]
|
|
|
|
|
|
@when('I transition from "{from_state}" to "{to_state}"')
|
|
def step_transition(context: Context, from_state: str, to_state: str) -> None:
|
|
context.transition_error = None
|
|
try:
|
|
context.tracker = transition_state(
|
|
context.tracker,
|
|
ContainerLifecycleState(to_state),
|
|
reason="test transition",
|
|
)
|
|
set_lifecycle_tracker(context.tracker)
|
|
except (ValueError, TypeError) as exc:
|
|
context.transition_error = exc
|
|
|
|
|
|
@when('I attempt to transition from "{from_state}" to "{to_state}"')
|
|
def step_attempt_transition(context: Context, from_state: str, to_state: str) -> None:
|
|
context.transition_error = None
|
|
try:
|
|
context.tracker = transition_state(
|
|
context.tracker,
|
|
ContainerLifecycleState(to_state),
|
|
reason="test transition",
|
|
)
|
|
set_lifecycle_tracker(context.tracker)
|
|
except (ValueError, TypeError) as exc:
|
|
context.transition_error = exc
|
|
|
|
|
|
@when("I call validate_transition with non-enum arguments")
|
|
def step_validate_transition_bad_types(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
validate_transition(
|
|
cast(ContainerLifecycleState, "inactive"),
|
|
cast(ContainerLifecycleState, "starting"),
|
|
)
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when("I call validate_transition with non-enum to_state")
|
|
def step_validate_transition_bad_to_state(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
validate_transition(
|
|
ContainerLifecycleState.DETECTED,
|
|
cast(ContainerLifecycleState, "starting"),
|
|
)
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when("I call transition_state with non-tracker argument")
|
|
def step_transition_state_bad_tracker(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
transition_state(
|
|
cast(ContainerLifecycleTracker, "not-a-tracker"),
|
|
ContainerLifecycleState.BUILDING,
|
|
)
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when("I call transition_state with non-enum to_state argument")
|
|
def step_transition_state_bad_to_state(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
tracker = ContainerLifecycleTracker(resource_id="01TESTLIFECYCLE0099000001")
|
|
transition_state(tracker, cast(ContainerLifecycleState, "starting"))
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when('I retrieve the tracker for "{resource_id}"')
|
|
def step_retrieve_tracker(context: Context, resource_id: str) -> None:
|
|
context.retrieved_tracker = get_lifecycle_tracker(resource_id)
|
|
|
|
|
|
@when("I list active containers")
|
|
def step_list_active(context: Context) -> None:
|
|
context.active_list = list_active_containers()
|
|
|
|
|
|
@when("I perform 105 transitions cycling through valid states")
|
|
def step_perform_105_transitions(context: Context) -> None:
|
|
"""Cycle detected->building->running->stopping->stopped->building->... 105 times."""
|
|
tracker = context.tracker
|
|
# First transition: detected -> building
|
|
tracker = transition_state(
|
|
tracker, ContainerLifecycleState.BUILDING, reason="t1-init"
|
|
)
|
|
cycle = [
|
|
ContainerLifecycleState.RUNNING,
|
|
ContainerLifecycleState.STOPPING,
|
|
ContainerLifecycleState.STOPPED,
|
|
ContainerLifecycleState.BUILDING,
|
|
]
|
|
# We've done 1 transition, need 104 more for 105 total
|
|
for i in range(104):
|
|
state = cycle[i % len(cycle)]
|
|
tracker = transition_state(tracker, state, reason=f"t1-{i}")
|
|
set_lifecycle_tracker(tracker)
|
|
context.tracker = tracker
|
|
|
|
|
|
@when("I attempt to get tracker with empty resource_id")
|
|
def step_get_tracker_empty(context: Context) -> None:
|
|
context.get_error = None
|
|
try:
|
|
get_lifecycle_tracker("")
|
|
except ValueError as exc:
|
|
context.get_error = exc
|
|
|
|
|
|
@when("I attempt to set tracker with wrong type")
|
|
def step_set_tracker_wrong_type(context: Context) -> None:
|
|
context.set_error = None
|
|
try:
|
|
set_lifecycle_tracker(cast(ContainerLifecycleTracker, "not-a-tracker"))
|
|
except TypeError as exc:
|
|
context.set_error = exc
|
|
|
|
|
|
@when("I run terminal tracker eviction")
|
|
def step_run_eviction(context: Context) -> None:
|
|
context.evicted_count = evict_terminal_trackers()
|
|
|
|
|
|
@when('I get tracker for new resource "{resource_id}"')
|
|
def step_get_new_tracker(context: Context, resource_id: str) -> None:
|
|
context.auto_tracker = get_lifecycle_tracker(resource_id)
|
|
|
|
|
|
@when("I clear the lifecycle registry")
|
|
def step_clear_registry(context: Context) -> None:
|
|
clear_lifecycle_registry()
|
|
|
|
|
|
# ── Then steps ───────────────────────────────────────────────
|
|
|
|
|
|
@then('the lifecycle enum should contain "{value}"')
|
|
def step_check_lifecycle_enum_value(context: Context, value: str) -> None:
|
|
assert value in context.enum_values, f"'{value}' not in {context.enum_values}"
|
|
|
|
|
|
@then("the transition should succeed")
|
|
def step_transition_ok(context: Context) -> None:
|
|
assert context.transition_error is None, (
|
|
f"Unexpected error: {context.transition_error}"
|
|
)
|
|
|
|
|
|
@then('the tracker state should be "{state}"')
|
|
def step_check_tracker_state(context: Context, state: str) -> None:
|
|
assert context.tracker.current_state == ContainerLifecycleState(state)
|
|
|
|
|
|
@then("the transition should raise ValueError")
|
|
def step_transition_error(context: Context) -> None:
|
|
assert context.transition_error is not None
|
|
assert isinstance(context.transition_error, ValueError)
|
|
|
|
|
|
@then("the tracker should have {count:d} transition in history")
|
|
def step_check_history_count(context: Context, count: int) -> None:
|
|
assert len(context.tracker.transitions) == count
|
|
|
|
|
|
@then('the retrieved tracker state should be "{state}"')
|
|
def step_check_retrieved_state(context: Context, state: str) -> None:
|
|
assert context.retrieved_tracker.current_state == ContainerLifecycleState(state)
|
|
|
|
|
|
@then('the active list should contain "{resource_id}"')
|
|
def step_check_active_contains(context: Context, resource_id: str) -> None:
|
|
assert resource_id in context.active_list
|
|
|
|
|
|
@then('the active list should not contain "{resource_id}"')
|
|
def step_check_active_not_contains(context: Context, resource_id: str) -> None:
|
|
assert resource_id not in context.active_list
|
|
|
|
|
|
@then("it should raise TypeError")
|
|
def step_check_type_error(context: Context) -> None:
|
|
assert context.type_error is not None
|
|
assert isinstance(context.type_error, TypeError)
|
|
|
|
|
|
@then("the tracker should have at most 100 transitions")
|
|
def step_check_max_transitions(context: Context) -> None:
|
|
assert len(context.tracker.transitions) <= 100, (
|
|
f"Expected at most 100 transitions, got {len(context.tracker.transitions)}"
|
|
)
|
|
|
|
|
|
@then("the get tracker should raise ValueError")
|
|
def step_check_get_error(context: Context) -> None:
|
|
assert context.get_error is not None
|
|
assert isinstance(context.get_error, ValueError)
|
|
|
|
|
|
@then("the set tracker should raise TypeError")
|
|
def step_check_set_error(context: Context) -> None:
|
|
assert context.set_error is not None
|
|
assert isinstance(context.set_error, TypeError)
|
|
|
|
|
|
@then("the registry should have at most {max_count:d} terminal trackers")
|
|
def step_check_registry_cap(context: Context, max_count: int) -> None:
|
|
with _registry_lock:
|
|
terminal = [
|
|
rid
|
|
for rid, t in _lifecycle_registry.items()
|
|
if t.current_state
|
|
in (ContainerLifecycleState.STOPPED, ContainerLifecycleState.FAILED)
|
|
]
|
|
assert len(terminal) <= max_count, (
|
|
f"Expected at most {max_count} terminal trackers, got {len(terminal)}"
|
|
)
|
|
|
|
|
|
@then("the eviction count should be {count:d}")
|
|
def step_check_eviction_count(context: Context, count: int) -> None:
|
|
assert context.evicted_count == count, (
|
|
f"Expected eviction count {count}, got {context.evicted_count}"
|
|
)
|
|
|
|
|
|
@then('the auto-created tracker state should be "{state}"')
|
|
def step_check_auto_tracker_state(context: Context, state: str) -> None:
|
|
assert context.auto_tracker.current_state == ContainerLifecycleState(state)
|
|
|
|
|
|
@then('the auto-created tracker resource_id should be "{resource_id}"')
|
|
def step_check_auto_tracker_id(context: Context, resource_id: str) -> None:
|
|
assert context.auto_tracker.resource_id == resource_id
|
|
|
|
|
|
@then("the registry should be empty")
|
|
def step_check_registry_empty(context: Context) -> None:
|
|
with _registry_lock:
|
|
assert len(_lifecycle_registry) == 0, (
|
|
f"Expected empty registry, got {len(_lifecycle_registry)} entries"
|
|
)
|
|
|
|
|
|
# ── JSON output parsing steps ────────────────────────────────
|
|
|
|
|
|
@when('I parse devcontainer up output with containerId "{ctr_id}" and workspace "{ws}"')
|
|
def step_parse_json(context: Context, ctr_id: str, ws: str) -> None:
|
|
stdout = json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": ctr_id,
|
|
"remoteWorkspaceFolder": ws,
|
|
}
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@when("I parse devcontainer up output with invalid JSON")
|
|
def step_parse_invalid_json(context: Context) -> None:
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(
|
|
"not valid json {{"
|
|
)
|
|
|
|
|
|
@when("I parse devcontainer up output with log lines before JSON")
|
|
def step_parse_multiline_json(context: Context) -> None:
|
|
stdout = (
|
|
"[2025-01-01T00:00:00Z] Starting container...\n"
|
|
"Some log line\n"
|
|
+ json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": "aabbccddee0099887766",
|
|
"remoteWorkspaceFolder": "/workspaces/multi",
|
|
}
|
|
)
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@when('I parse devcontainer up output with invalid container ID "{bad_id}"')
|
|
def step_parse_invalid_container_id(context: Context, bad_id: str) -> None:
|
|
stdout = json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": bad_id,
|
|
"remoteWorkspaceFolder": "/workspaces/project",
|
|
}
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@when('I parse devcontainer up output with relative workspace "{ws}"')
|
|
def step_parse_relative_ws(context: Context, ws: str) -> None:
|
|
stdout = json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": "aabbccddee0011223344",
|
|
"remoteWorkspaceFolder": ws,
|
|
}
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@then('the parsed container ID should be "{ctr_id}"')
|
|
def step_check_parsed_ctr_id(context: Context, ctr_id: str) -> None:
|
|
assert context.parsed_ctr_id == ctr_id
|
|
|
|
|
|
@then("the parsed container ID should be None")
|
|
def step_check_parsed_ctr_id_none(context: Context) -> None:
|
|
assert context.parsed_ctr_id is None
|
|
|
|
|
|
@then('the parsed workspace path should be "{ws}"')
|
|
def step_check_parsed_ws(context: Context, ws: str) -> None:
|
|
assert context.parsed_ws == ws
|
|
|
|
|
|
@then("the parsed workspace path should be None")
|
|
def step_check_parsed_ws_none(context: Context) -> None:
|
|
assert context.parsed_ws is None
|
|
|
|
|
|
@then("the parsed container ID should be empty")
|
|
def step_check_empty_container_id(context: Context) -> None:
|
|
assert context.parsed_ctr_id is None, (
|
|
f"Expected None container ID, got '{context.parsed_ctr_id}'"
|
|
)
|
|
|
|
|
|
@then("the runner should not have received a devcontainer up call")
|
|
def step_check_no_up_call(context: Context) -> None:
|
|
assert len(context.mock_runner.up_calls) == 0, (
|
|
f"Expected 0 devcontainer up calls, got {len(context.mock_runner.up_calls)}"
|
|
)
|