fix(resources): allow agents resource stop to stop container-instance and devcontainer-instance resources #3250

Merged
freemo merged 2 commits from fix/container-instance-stop into master 2026-04-10 00:30:49 +00:00
4 changed files with 211 additions and 10 deletions
+4 -4
View File
@@ -135,14 +135,14 @@ Feature: Devcontainer Session Cleanup and CLI Commands
Then the devcontainer CLI exit code should be 0
And the CLI rebuild mock should have been called once
# ── F5/F19: container-instance is NOT stoppable via CLI ────
# ── Issue #2588: container-instance IS stoppable via CLI ────
Scenario: CLI stop rejects container-instance type (F19 fix)
Scenario: CLI stop accepts container-instance type (issue #2588 fix)
Given a mock resource service with container-instance "local/ctr-stop" id "01TESTLIFECYCLE0000000370" at "/workspace/project"
And an active container "01TESTLIFECYCLE0000000370" with container ID "ctr-ci-stop"
When I invoke CLI resource stop "local/ctr-stop"
Then the devcontainer CLI exit code should be non-zero
And the CLI output should contain "not a stoppable"
Then the CLI exit code should be 0
And the CLI output should contain "Stopped"
# ── F6-r6: CLI handler-level error paths ────────────────────
@@ -0,0 +1,165 @@
"""Step definitions for TDD issue #2588.
Verifies that ``agents resource stop`` accepts both ``container-instance``
and ``devcontainer-instance`` resource types, and still rejects other types.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.resource import _STOPPABLE_TYPES
from cleveragents.cli.commands.resource import app as resource_app
from cleveragents.domain.models.core.container_lifecycle import (
ContainerLifecycleState,
ContainerLifecycleTracker,
)
from cleveragents.resource.handlers.devcontainer import set_lifecycle_tracker
_CLI_PATCH_SERVICE = "cleveragents.cli.commands.resource._get_registry_service"
_CLI_PATCH_STOP = "cleveragents.cli.commands.resource.stop_container"
def _make_mock_resource(
*,
resource_id: str,
name: str,
resource_type_name: str,
location: str | None = None,
) -> MagicMock:
res = MagicMock()
res.resource_id = resource_id
res.name = name
res.resource_type_name = resource_type_name
res.location = location
res.properties = None
return res
@given('a running container-instance resource "{name}" with id "{resource_id}"')
def step_running_container_instance(
context: Context, name: str, resource_id: str
) -> None:
"""Set up a running container-instance resource for stop tests."""
mock_service = MagicMock()
mock_resource = _make_mock_resource(
resource_id=resource_id,
name=name,
resource_type_name="container-instance",
location="/workspace/project",
)
mock_service.show_resource.return_value = mock_resource
context.tdd2588_mock_service = mock_service
context.tdd2588_resource_id = resource_id
# Set up lifecycle tracker in running state
tracker = ContainerLifecycleTracker(
resource_id=resource_id,
current_state=ContainerLifecycleState.RUNNING,
container_id="ctr-2588-ci",
workspace_path="/workspace/project",
)
set_lifecycle_tracker(tracker)
Review

Resource cleanup verified. The set_lifecycle_tracker() calls here register trackers in the global _lifecycle_registry. I confirmed that features/environment.py (line 606) calls clear_lifecycle_registry() in before_scenario, which properly clears the registry, signals health check threads to stop, and joins them with a 2s timeout. No memory leak risk.

The patch() context managers in step_invoke_resource_stop also properly restore mocked functions when the with block exits. Clean test isolation.

✅ **Resource cleanup verified.** The `set_lifecycle_tracker()` calls here register trackers in the global `_lifecycle_registry`. I confirmed that `features/environment.py` (line 606) calls `clear_lifecycle_registry()` in `before_scenario`, which properly clears the registry, signals health check threads to stop, and joins them with a 2s timeout. No memory leak risk. The `patch()` context managers in `step_invoke_resource_stop` also properly restore mocked functions when the `with` block exits. Clean test isolation.
@given('a running devcontainer-instance resource "{name}" with id "{resource_id}"')
def step_running_devcontainer_instance(
context: Context, name: str, resource_id: str
) -> None:
"""Set up a running devcontainer-instance resource for stop tests."""
mock_service = MagicMock()
mock_resource = _make_mock_resource(
resource_id=resource_id,
name=name,
resource_type_name="devcontainer-instance",
location="/workspace/project",
)
mock_service.show_resource.return_value = mock_resource
context.tdd2588_mock_service = mock_service
context.tdd2588_resource_id = resource_id
# Set up lifecycle tracker in running state
tracker = ContainerLifecycleTracker(
resource_id=resource_id,
current_state=ContainerLifecycleState.RUNNING,
container_id="ctr-2588-dc",
workspace_path="/workspace/project",
)
set_lifecycle_tracker(tracker)
@given('a git-checkout resource "{name}" with id "{resource_id}"')
def step_git_checkout_resource(context: Context, name: str, resource_id: str) -> None:
"""Set up a git-checkout resource (non-stoppable) for stop tests."""
mock_service = MagicMock()
mock_resource = _make_mock_resource(
resource_id=resource_id,
name=name,
resource_type_name="git-checkout",
)
mock_service.show_resource.return_value = mock_resource
context.tdd2588_mock_service = mock_service
context.tdd2588_resource_id = resource_id
@when('I invoke resource stop for "{name}" with --yes')
def step_invoke_resource_stop(context: Context, name: str) -> None:
"""Invoke the resource stop CLI command with --yes flag."""
runner = CliRunner()
mock_service = context.tdd2588_mock_service
with (
patch(_CLI_PATCH_SERVICE, return_value=mock_service),
patch(_CLI_PATCH_STOP) as mock_stop,
):
mock_stop.return_value = MagicMock()
Review

[SUGGESTION] (non-blocking)

mock_stop.return_value = MagicMock() — the real stop_container returns a ContainerLifecycleTracker. For type fidelity, consider returning the tracker set up in the Given step.

Also, mock_stop is captured on context.tdd2588_mock_stop but never asserted — consider adding a step to verify stop_container was called with the correct resource_id.

**[SUGGESTION]** (non-blocking) `mock_stop.return_value = MagicMock()` — the real `stop_container` returns a `ContainerLifecycleTracker`. For type fidelity, consider returning the tracker set up in the Given step. Also, `mock_stop` is captured on `context.tdd2588_mock_stop` but never asserted — consider adding a step to verify `stop_container` was called with the correct `resource_id`.
Review

[Non-blocking] mock_stop.return_value = MagicMock() — the real stop_container returns a ContainerLifecycleTracker. Consider returning the tracker from the Given step for type fidelity. Also, context.tdd2588_mock_stop is captured but never asserted — consider verifying stop_container was called with the expected resource_id.

**[Non-blocking]** `mock_stop.return_value = MagicMock()` — the real `stop_container` returns a `ContainerLifecycleTracker`. Consider returning the tracker from the Given step for type fidelity. Also, `context.tdd2588_mock_stop` is captured but never asserted — consider verifying `stop_container` was called with the expected resource_id.
result = runner.invoke(resource_app, ["stop", name, "--yes"])
context.tdd2588_result = result
context.tdd2588_mock_stop = mock_stop
@when("I inspect the _STOPPABLE_TYPES constant")
def step_inspect_stoppable_types(context: Context) -> None:
"""Capture the _STOPPABLE_TYPES constant for inspection."""
context.tdd2588_stoppable_types = _STOPPABLE_TYPES
@then("the stop exit code should be 0")
def step_stop_exit_code_zero(context: Context) -> None:
result = context.tdd2588_result
assert result.exit_code == 0, (
f"Expected exit code 0, got {result.exit_code}. Output:\n{result.output}"
)
@then("the stop exit code should be non-zero")
def step_stop_exit_code_nonzero(context: Context) -> None:
result = context.tdd2588_result
assert result.exit_code != 0, (
f"Expected non-zero exit code, got {result.exit_code}. Output:\n{result.output}"
)
@then('the stop output should contain "{text}"')
def step_stop_output_contains(context: Context, text: str) -> None:
result = context.tdd2588_result
assert text in result.output, f"Expected '{text}' in output, got:\n{result.output}"
@then('_STOPPABLE_TYPES should contain "{resource_type}"')
def step_stoppable_types_contains(context: Context, resource_type: str) -> None:
stoppable = context.tdd2588_stoppable_types
assert resource_type in stoppable, (
f"Expected '{resource_type}' in _STOPPABLE_TYPES, got: {stoppable}"
)
@then('_STOPPABLE_TYPES should not contain "{resource_type}"')
def step_stoppable_types_not_contains(context: Context, resource_type: str) -> None:
stoppable = context.tdd2588_stoppable_types
assert resource_type not in stoppable, (
f"Expected '{resource_type}' NOT in _STOPPABLE_TYPES, got: {stoppable}"
)
@@ -0,0 +1,35 @@
@tdd_issue @tdd_issue_2588
Feature: TDD Issue #2588 — agents resource stop rejects container-instance resources
Review

[BLOCKING] Feature title describes the bug, not the fix.

This title reads "agents resource stop rejects container-instance resources" but all four scenarios test the opposite — that container-instance IS accepted. In BDD, Feature titles serve as documentation of desired behavior.

Required: Change to:

Feature: TDD Issue #2588 — agents resource stop accepts container-instance resources

⚠️ Flagged in five prior reviews.

**[BLOCKING] Feature title describes the bug, not the fix.** This title reads "agents resource stop **rejects** container-instance resources" but all four scenarios test the **opposite** — that container-instance IS accepted. In BDD, Feature titles serve as documentation of desired behavior. **Required**: Change to: ``` Feature: TDD Issue #2588 — agents resource stop accepts container-instance resources ``` ⚠️ Flagged in five prior reviews.
Review

Non-blocking: The feature title says "agents resource stop rejects container-instance resources" but the scenarios verify it accepts them. The title describes the old bug, not the fixed behavior. Consider updating to: "agents resource stop accepts both container-instance and devcontainer-instance resources"

**Non-blocking**: The feature title says "agents resource stop **rejects** container-instance resources" but the scenarios verify it **accepts** them. The title describes the old bug, not the fixed behavior. Consider updating to: *"agents resource stop accepts both container-instance and devcontainer-instance resources"*
Review

[Blocking] Feature title says "rejects" but all scenarios test that container-instance IS accepted. Change to:
Feature: TDD Issue #2588 — agents resource stop accepts container-instance resources

**[Blocking]** Feature title says "rejects" but all scenarios test that container-instance IS accepted. Change to: `Feature: TDD Issue #2588 — agents resource stop accepts container-instance resources`
As a CleverAgents user
I want to stop container-instance resources via agents resource stop
So that both container-instance and devcontainer-instance resources
can be stopped as required by the specification
The specification states:
"Stop an active devcontainer-instance or container-instance resource.
Transitions the container through running -> stopping -> stopped.
Only container-typed resources may be stopped."
Scenario: container-instance is accepted by resource stop
Given a running container-instance resource "local/my-ctr" with id "01TDD2588CONTAINERINST001"
When I invoke resource stop for "local/my-ctr" with --yes
Then the stop exit code should be 0
And the stop output should contain "Stopped"
Scenario: devcontainer-instance is still accepted by resource stop
Given a running devcontainer-instance resource "local/my-dc" with id "01TDD2588DEVCONTAINERINST1"
When I invoke resource stop for "local/my-dc" with --yes
Then the stop exit code should be 0
And the stop output should contain "Stopped"
Scenario: non-container resource type is still rejected by resource stop
Given a git-checkout resource "local/my-repo" with id "01TDD2588GITCHECKOUT00001"
When I invoke resource stop for "local/my-repo" with --yes
Then the stop exit code should be non-zero
And the stop output should contain "not a stoppable container type"
Scenario: _STOPPABLE_TYPES contains both container types
When I inspect the _STOPPABLE_TYPES constant
Then _STOPPABLE_TYPES should contain "container-instance"
And _STOPPABLE_TYPES should contain "devcontainer-instance"
And _STOPPABLE_TYPES should not contain "git-checkout"
+7 -6
View File
1
@@ -1434,9 +1434,10 @@ def resource_remove(
# B2 fix: separate type sets — stop works on any container, but rebuild
# requires devcontainer-instance since it invokes ``devcontainer up``.
# F19 fix: restricted to devcontainer-instance only — container-instance
# has no lifecycle tracker wiring, so stop/rebuild would fail at runtime.
_STOPPABLE_TYPES = frozenset({"devcontainer-instance"})
# Issue #2588 fix: container-instance is now stoppable — stop_container uses
# docker stop with the container_id from the lifecycle tracker, which works
# for both container-instance and devcontainer-instance resources.
_STOPPABLE_TYPES = frozenset({"devcontainer-instance", "container-instance"})
_REBUILDABLE_TYPES = frozenset({"devcontainer-instance"})
# F5-r6 fix: moved from inside resource_rebuild() to module level to avoid
# per-call allocation (consistent with _STOPPABLE_TYPES / _REBUILDABLE_TYPES).
@@ -1457,11 +1458,11 @@ def resource_stop(
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
) -> None:
"""Stop a running devcontainer-instance resource.
"""Stop a running container-instance or devcontainer-instance resource.
Transitions the container from ``running`` -> ``stopping`` -> ``stopped``.
Only devcontainer-instance and container-instance resources
may be stopped.
Both container-instance and devcontainer-instance resources
may be stopped; other resource types produce an error.
Use ``--yes`` / ``-y`` to skip the confirmation prompt in scripts.