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
276 lines
9.3 KiB
Python
276 lines
9.3 KiB
Python
"""Helper utilities for devcontainer lifecycle Robot smoke tests.
|
|
|
|
Each command prints a single ``<command>-ok`` token on success so the
|
|
calling Robot test can assert on ``stdout``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
|
|
from cleveragents.application.services.cleanup_service import CleanupService
|
|
from cleveragents.domain.models.core.container_lifecycle import (
|
|
ContainerLifecycleState,
|
|
ContainerLifecycleTracker,
|
|
transition_state,
|
|
)
|
|
from cleveragents.resource.handlers.devcontainer import (
|
|
_parse_devcontainer_up_output,
|
|
activate_container,
|
|
clear_lifecycle_registry,
|
|
get_lifecycle_tracker,
|
|
rebuild_container,
|
|
set_lifecycle_tracker,
|
|
stop_all_active_containers,
|
|
stop_container,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F29 fix: removed conditional import with ``# type: ignore`` suppressions.
|
|
# The inline mock classes are always used — they are self-contained and
|
|
# avoid the PYTHONPATH dependency on ``features/``, which is not always
|
|
# available in standalone Robot runs.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _MockResult:
|
|
"""Minimal subprocess.CompletedProcess stand-in."""
|
|
|
|
returncode: int = 0
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
args: list[str] = field(default_factory=list)
|
|
|
|
|
|
class _MockRunner:
|
|
"""Callable mock for subprocess.run used by lifecycle functions."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[list[str], dict[str, object]]] = []
|
|
self.up_result: _MockResult = _MockResult()
|
|
self.exec_result: _MockResult = _MockResult()
|
|
self.stop_result: _MockResult = _MockResult()
|
|
|
|
def __call__(self, args: list[str], **kwargs: object) -> _MockResult:
|
|
self.calls.append((list(args), dict(kwargs)))
|
|
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up":
|
|
return self.up_result
|
|
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "exec":
|
|
return self.exec_result
|
|
if len(args) >= 2 and args[0] == "docker" and args[1] == "stop":
|
|
return self.stop_result
|
|
return _MockResult(returncode=0)
|
|
|
|
def set_up_result(
|
|
self,
|
|
*,
|
|
container_id: str = "aabbccddee0011223344",
|
|
workspace: str = "/workspaces/project",
|
|
returncode: int = 0,
|
|
) -> None:
|
|
output = json.dumps(
|
|
{
|
|
"outcome": "success" if returncode == 0 else "error",
|
|
"containerId": container_id,
|
|
"remoteWorkspaceFolder": workspace,
|
|
}
|
|
)
|
|
self.up_result = _MockResult(returncode=returncode, stdout=output)
|
|
|
|
@property
|
|
def up_calls(self) -> list[tuple[list[str], dict[str, object]]]:
|
|
return [
|
|
(a, k)
|
|
for a, k in self.calls
|
|
if len(a) >= 2 and a[0] == "devcontainer" and a[1] == "up"
|
|
]
|
|
|
|
@property
|
|
def stop_calls(self) -> list[tuple[list[str], dict[str, object]]]:
|
|
return [
|
|
(a, k)
|
|
for a, k in self.calls
|
|
if len(a) >= 2 and a[0] == "docker" and a[1] == "stop"
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def cmd_enum_values() -> None:
|
|
"""Verify all six lifecycle states exist."""
|
|
expected = {"detected", "building", "running", "stopping", "stopped", "failed"}
|
|
actual = {s.value for s in ContainerLifecycleState}
|
|
assert actual == expected, f"Expected {expected}, got {actual}"
|
|
print("enum-values-ok")
|
|
|
|
|
|
def cmd_transition_valid() -> None:
|
|
"""Verify detected->building transition."""
|
|
clear_lifecycle_registry()
|
|
tracker = ContainerLifecycleTracker(resource_id="01ROBOTTEST0000000000001")
|
|
tracker = transition_state(tracker, ContainerLifecycleState.BUILDING, reason="test")
|
|
assert tracker.current_state == ContainerLifecycleState.BUILDING
|
|
assert len(tracker.transitions) == 1
|
|
print("transition-valid-ok")
|
|
|
|
|
|
def cmd_transition_invalid() -> None:
|
|
"""Verify invalid transitions raise ValueError."""
|
|
clear_lifecycle_registry()
|
|
tracker = ContainerLifecycleTracker(resource_id="01ROBOTTEST0000000000002")
|
|
try:
|
|
transition_state(tracker, ContainerLifecycleState.RUNNING, reason="bad")
|
|
raise AssertionError("Should have raised ValueError")
|
|
except ValueError:
|
|
pass
|
|
print("transition-invalid-ok")
|
|
|
|
|
|
def cmd_lazy_activation() -> None:
|
|
"""Verify lazy activation with mock runner."""
|
|
clear_lifecycle_registry()
|
|
runner = _MockRunner()
|
|
runner.set_up_result(container_id="aabbccddee0022334455", workspace="/ws")
|
|
result = activate_container(
|
|
"01ROBOTTEST0000000000010",
|
|
"/workspace",
|
|
run_command=runner,
|
|
)
|
|
assert result.current_state == ContainerLifecycleState.RUNNING
|
|
assert result.container_id == "aabbccddee0022334455"
|
|
assert len(runner.up_calls) == 1
|
|
print("lazy-activation-ok")
|
|
|
|
|
|
def cmd_stop_container() -> None:
|
|
"""Verify stop transitions active to stopped."""
|
|
clear_lifecycle_registry()
|
|
runner = _MockRunner()
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id="01ROBOTTEST0000000000020",
|
|
current_state=ContainerLifecycleState.RUNNING,
|
|
container_id="ctr-stop",
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
stop_container("01ROBOTTEST0000000000020", run_command=runner)
|
|
final = get_lifecycle_tracker("01ROBOTTEST0000000000020")
|
|
assert final.current_state == ContainerLifecycleState.STOPPED
|
|
assert len(runner.stop_calls) == 1
|
|
print("stop-container-ok")
|
|
|
|
|
|
def cmd_rebuild_container() -> None:
|
|
"""Verify rebuild transitions stopped to running."""
|
|
clear_lifecycle_registry()
|
|
runner = _MockRunner()
|
|
runner.set_up_result(container_id="aabbccddee0033445566", workspace="/ws")
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id="01ROBOTTEST0000000000030",
|
|
current_state=ContainerLifecycleState.STOPPED,
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
# R16 fix: call rebuild_container instead of activate_container
|
|
rebuild_container(
|
|
"01ROBOTTEST0000000000030",
|
|
"/workspace",
|
|
run_command=runner,
|
|
)
|
|
final = get_lifecycle_tracker("01ROBOTTEST0000000000030")
|
|
assert final.current_state == ContainerLifecycleState.RUNNING
|
|
print("rebuild-container-ok")
|
|
|
|
|
|
def cmd_session_cleanup() -> None:
|
|
"""Verify session cleanup stops all active containers."""
|
|
clear_lifecycle_registry()
|
|
runner = _MockRunner()
|
|
for rid in ("01ROBOTTEST0000000000040", "01ROBOTTEST0000000000041"):
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id=rid,
|
|
current_state=ContainerLifecycleState.RUNNING,
|
|
container_id=f"ctr-{rid[-3:]}",
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
stopped = stop_all_active_containers(run_command=runner, session_id="ses-001")
|
|
assert len(stopped) == 2
|
|
for rid in ("01ROBOTTEST0000000000040", "01ROBOTTEST0000000000041"):
|
|
t = get_lifecycle_tracker(rid)
|
|
assert t.current_state == ContainerLifecycleState.STOPPED
|
|
print("session-cleanup-ok")
|
|
|
|
|
|
def cmd_json_parsing() -> None:
|
|
"""Verify devcontainer up JSON output parsing."""
|
|
stdout = json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": "aabbccddee0044556677",
|
|
"remoteWorkspaceFolder": "/parsed/ws",
|
|
}
|
|
)
|
|
ctr_id, ws = _parse_devcontainer_up_output(stdout)
|
|
assert ctr_id == "aabbccddee0044556677"
|
|
assert ws == "/parsed/ws"
|
|
|
|
# Invalid JSON
|
|
ctr_id2, ws2 = _parse_devcontainer_up_output("not json {{")
|
|
assert ctr_id2 is None
|
|
assert ws2 is None
|
|
print("json-parsing-ok")
|
|
|
|
|
|
def cmd_registry_persist() -> None:
|
|
"""Verify tracker persists in registry."""
|
|
clear_lifecycle_registry()
|
|
tracker = ContainerLifecycleTracker(resource_id="01ROBOTTEST0000000000060")
|
|
set_lifecycle_tracker(tracker)
|
|
tracker2 = transition_state(tracker, ContainerLifecycleState.BUILDING, reason="t")
|
|
set_lifecycle_tracker(tracker2)
|
|
retrieved = get_lifecycle_tracker("01ROBOTTEST0000000000060")
|
|
assert retrieved.current_state == ContainerLifecycleState.BUILDING
|
|
print("registry-persist-ok")
|
|
|
|
|
|
def cmd_cleanup_method() -> None:
|
|
"""Verify CleanupService.stop_active_devcontainers exists."""
|
|
assert hasattr(CleanupService, "stop_active_devcontainers")
|
|
assert callable(CleanupService.stop_active_devcontainers)
|
|
print("cleanup-method-ok")
|
|
|
|
|
|
_COMMANDS = {
|
|
"enum-values": cmd_enum_values,
|
|
"transition-valid": cmd_transition_valid,
|
|
"transition-invalid": cmd_transition_invalid,
|
|
"lazy-activation": cmd_lazy_activation,
|
|
"stop-container": cmd_stop_container,
|
|
"rebuild-container": cmd_rebuild_container,
|
|
"session-cleanup": cmd_session_cleanup,
|
|
"json-parsing": cmd_json_parsing,
|
|
"registry-persist": cmd_registry_persist,
|
|
"cleanup-method": cmd_cleanup_method,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
"""Dispatch subcommand from argv."""
|
|
if len(sys.argv) < 2:
|
|
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
|
|
sys.exit(1)
|
|
cmd = sys.argv[1]
|
|
func = _COMMANDS.get(cmd)
|
|
if func is None:
|
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
sys.exit(1)
|
|
func()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|