---
phase: 02-core-vm-lifecycle
plan: 04
type: execute
wave: 4
depends_on: ["02-03"]
files_modified:
  - vmcluster_mcp/cluster/__init__.py
  - tests/test_cluster_status.py
autonomous: true
requirements:
  - CLUS-05
  - CLUS-06

must_haves:
  truths:
    - "cluster_status returns domain state and SSH reachability for each node"
    - "cluster_status returns network_active boolean"
    - "cluster_handle returns a ClusterHandle with node names, IPs, and SSH credentials"
    - "cluster_handle on a non-running cluster returns an error"
  artifacts:
    - path: "vmcluster_mcp/cluster/__init__.py"
      provides: "cluster_status and cluster_handle tool registrations"
      contains: "cluster_status"
    - path: "tests/test_cluster_status.py"
      provides: "Unit tests for status and handle tools"
      min_lines: 60
  key_links:
    - from: "vmcluster_mcp/cluster/__init__.py"
      to: "vmcluster_mcp/cluster/libvirt_client.py"
      via: "Domain state queries for status"
      pattern: "get_domain_state|is_domain_active|is_network_active"
    - from: "vmcluster_mcp/cluster/__init__.py"
      to: "vmcluster_mcp/models.py"
      via: "ClusterHandle, ClusterStatus, NodeDescriptor return types"
      pattern: "ClusterHandle|ClusterStatus"
---

<objective>
Add cluster_status and cluster_handle query tools — the read-only operations that let agents inspect cluster state and obtain SSH descriptors for downstream consumers.

Purpose: cluster_status enables agents to check if nodes are running/reachable before proceeding. cluster_handle produces the ClusterHandle descriptor that downstream consumers (Phase 3 SSH, etc.) use for direct node access.

Output: Two new tools in cluster/__init__.py plus tests.
</objective>

<execution_context>
@~/.copilot/get-shit-done/workflows/execute-plan.md
@~/.copilot/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-core-vm-lifecycle/02-CONTEXT.md
@.planning/phases/02-core-vm-lifecycle/02-RESEARCH.md
@.planning/phases/02-core-vm-lifecycle/02-01-SUMMARY.md
@.planning/phases/02-core-vm-lifecycle/02-02-SUMMARY.md
@.planning/phases/02-core-vm-lifecycle/02-03-SUMMARY.md

<interfaces>
<!-- From Plans 01-03 -->

From vmcluster_mcp/cluster/libvirt_client.py:
```python
def domain_name(cluster_name: str, node_name: str) -> str: ...

class LibvirtClient:
    async def lookup_domain(self, name: str) -> virDomain | None: ...
    async def get_domain_state(self, dom) -> str: ...  # "running", "shut off", etc.
    async def is_domain_active(self, dom) -> bool: ...
    async def lookup_network(self, name: str) -> virNetwork | None: ...
    async def is_network_active(self, net) -> bool: ...
```

From vmcluster_mcp/cluster/__init__.py (Plans 02-03 output):
```python
_client = LibvirtClient(config.libvirt_uri)
async def _wait_for_ssh(ip, port, user, key_path, timeout_s) -> tuple[bool, float]: ...
# cluster_define, cluster_start, cluster_stop, cluster_destroy already registered
```

From vmcluster_mcp/models.py:
```python
class ClusterHandle(BaseModel):
    cluster_name: str
    nodes: list[NodeDescriptor]
    artifact_path: str        # Absolute path on nodes where build tree is installed
    kernel_version: str       # uname -r of running kernel

class NodeDescriptor(BaseModel):
    name: str; role: str; ip: str; ssh_port: int = 22
    ssh_user: str = "root"; ssh_key_path: str

class ClusterStatus(BaseModel):
    cluster_name: str; network_active: bool; nodes: list[NodeStatus]

class NodeStatus(BaseModel):
    name: str; role: str; ip: str
    domain_state: Literal["running", "shut off", "paused", "crashed", "unknown"]
    ssh_reachable: bool; artifact_id_installed: str | None = None
    loaded_modules: list[str] = []

class ToolResult(BaseModel, Generic[T]):
    success: bool; result: T | None = None; error: ErrorDetail | None = None
```

Design doc cluster_status signature:
```
cluster_status(cluster_name: str) -> ClusterStatus
cluster_handle(cluster_name: str) -> ClusterHandle
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add cluster_status tool</name>
  <files>vmcluster_mcp/cluster/__init__.py, tests/test_cluster_status.py</files>
  <read_first>
    - vmcluster_mcp/cluster/__init__.py (current state — cluster_define, start, stop, destroy, _client, _wait_for_ssh)
    - vmcluster_mcp/cluster/libvirt_client.py (lookup_domain, get_domain_state, is_network_active, lookup_network)
    - vmcluster_mcp/models.py (ClusterStatus, NodeStatus fields)
    - vmcluster_mcp/topology/schema.py (TopologySpec, NodeSpec)
    - docs/vmcluster-mcp-design.md §2.4 (cluster_status returns domain state + SSH reachability)
  </read_first>
  <behavior>
    - Test: cluster_status returns ClusterStatus with correct cluster_name
    - Test: cluster_status returns network_active=True when network is active
    - Test: cluster_status returns per-node domain_state from libvirt (running, shut off, etc.)
    - Test: cluster_status checks SSH reachability for running nodes (quick timeout)
    - Test: cluster_status sets ssh_reachable=False for non-running nodes without attempting SSH
    - Test: cluster_status on unknown cluster returns error
    - Test: cluster_status with partially running cluster returns mixed states
  </behavior>
  <action>
    Append to `vmcluster_mcp/cluster/__init__.py`:

    **cluster_status:**
    `@mcp.tool()` + `@structured_tool_handler`
    `async def cluster_status(cluster_name: str) -> ToolResult[ClusterStatus]:`

    1. `topo = load_topology(cluster_name, config.topology_dir)`
    2. Check network: `net = await _client.lookup_network(topo.network.name)`
       - `network_active = bool(net and await _client.is_network_active(net))`
    3. For each node in `topo.nodes`:
       - `dom_name = domain_name(topo.cluster_name, node.name)`
       - `dom = await _client.lookup_domain(dom_name)`
       - If not found: `domain_state = "unknown"`, `ssh_reachable = False`
       - If found: `domain_state = await _client.get_domain_state(dom)`
       - If `domain_state == "running"`: check SSH with short timeout (5s):
         `reachable, _ = await _wait_for_ssh(node.ip, 22, topo.ssh.user, topo.ssh.key_path, 5)`
         `ssh_reachable = reachable`
       - Else: `ssh_reachable = False`
       - Build `NodeStatus(name=node.name, role=node.role, ip=node.ip, domain_state=domain_state, ssh_reachable=ssh_reachable)`
    4. Return `ToolResult(success=True, result=ClusterStatus(cluster_name=topo.cluster_name, network_active=network_active, nodes=node_statuses))`

    Use `asyncio.gather` to check SSH for all running nodes in parallel (not sequentially).
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_cluster_status.py -x -v -k "status" 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - cluster/__init__.py contains `async def cluster_status(` with `@mcp.tool()`
    - cluster_status returns ClusterStatus with network_active and per-node NodeStatus
    - SSH reachability only checked for running nodes (not shut off/paused)
    - SSH checks run in parallel via asyncio.gather
    - Non-running nodes get ssh_reachable=False without SSH attempt
    - Tests pass
  </acceptance_criteria>
  <done>cluster_status returns accurate domain states and SSH reachability for all cluster nodes</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add cluster_handle tool</name>
  <files>vmcluster_mcp/cluster/__init__.py, tests/test_cluster_status.py</files>
  <read_first>
    - vmcluster_mcp/cluster/__init__.py (current state — all tools so far)
    - vmcluster_mcp/models.py (ClusterHandle, NodeDescriptor fields)
    - vmcluster_mcp/topology/schema.py (TopologySpec — cluster_name, nodes, ssh)
    - vmcluster_mcp/config.py (config.ssh_key_path, config.ssh_user)
    - docs/vmcluster-mcp-design.md §1 (ClusterHandle = handoff interface, includes artifact_path and kernel_version)
  </read_first>
  <behavior>
    - Test: cluster_handle returns ClusterHandle with correct cluster_name
    - Test: cluster_handle returns NodeDescriptor for each node with name, role, ip, ssh_port, ssh_user, ssh_key_path
    - Test: cluster_handle includes artifact_path from config
    - Test: cluster_handle retrieves kernel_version from running node via SSH (uname -r)
    - Test: cluster_handle on non-running cluster returns error
    - Test: cluster_handle NodeDescriptor.ssh_key_path comes from topology ssh.key_path
  </behavior>
  <action>
    Append to `vmcluster_mcp/cluster/__init__.py`:

    **cluster_handle:**
    `@mcp.tool()` + `@structured_tool_handler`
    `async def cluster_handle(cluster_name: str) -> ToolResult[ClusterHandle]:`

    1. `topo = load_topology(cluster_name, config.topology_dir)`
    2. Verify cluster is running: check first node's domain state
       - `dom = await _client.lookup_domain(domain_name(topo.cluster_name, topo.nodes[0].name))`
       - If not found or not active: return `ToolResult(success=False, error=ErrorDetail(code="CLUSTER_NOT_RUNNING", message="Cluster must be running to get handle", recoverable=True))`
    3. Get kernel version from first node via SSH:
       ```python
       try:
           async with asyncssh.connect(
               topo.nodes[0].ip, port=22, username=topo.ssh.user,
               client_keys=[topo.ssh.key_path], known_hosts=None, login_timeout=10
           ) as conn:
               result = await conn.run("uname -r", check=True)
               kernel_version = result.stdout.strip()
       except (OSError, asyncssh.Error) as e:
           return ToolResult(success=False, error=ErrorDetail(
               code="SSH_ERROR", message=f"Cannot determine kernel version: {e}", recoverable=True))
       ```
    4. Build NodeDescriptors:
       ```python
       nodes = [
           NodeDescriptor(
               name=node.name, role=node.role, ip=node.ip,
               ssh_port=22, ssh_user=topo.ssh.user,
               ssh_key_path=topo.ssh.key_path,
           )
           for node in topo.nodes
       ]
       ```
    5. `artifact_path = f"/opt/vmcluster/artifacts"` — standard artifact destination (matches design doc §2.4 artifact_sync dest_base default)
    6. Return `ToolResult(success=True, result=ClusterHandle(cluster_name=topo.cluster_name, nodes=nodes, artifact_path=artifact_path, kernel_version=kernel_version))`

    **Add tests to tests/test_cluster_status.py** for cluster_handle with mocked asyncssh and libvirt client.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_cluster_status.py -x -v 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - cluster/__init__.py contains `async def cluster_handle(` with `@mcp.tool()`
    - cluster_handle returns ClusterHandle with cluster_name, nodes, artifact_path, kernel_version
    - Each NodeDescriptor has name, role, ip, ssh_port=22, ssh_user, ssh_key_path from topology
    - kernel_version obtained via `uname -r` SSH command on first node
    - Non-running cluster returns ToolResult(success=False, error=CLUSTER_NOT_RUNNING)
    - artifact_path is "/opt/vmcluster/artifacts"
    - All tests pass: `pytest tests/test_cluster_status.py` exits 0
    - ruff check passes
  </acceptance_criteria>
  <done>cluster_handle returns complete ClusterHandle descriptor for downstream SSH consumers, cluster_status and cluster_handle both tested and passing</done>
</task>

</tasks>

<verification>
```bash
# All plan tests pass
python -m pytest tests/test_cluster_status.py -x -v

# ALL Phase 2 tests pass
python -m pytest tests/ -x -v

# Ruff clean
ruff check vmcluster_mcp/cluster/

# All 6 cluster tools registered
python -c "
from vmcluster_mcp.cluster import (
    cluster_define, cluster_start, cluster_stop,
    cluster_destroy, cluster_status, cluster_handle
)
print('All 6 cluster tools registered')
"

# Requirement coverage
# CLUS-01: cluster_define ✓ (Plan 02)
# CLUS-02: cluster_start ✓ (Plan 03)
# CLUS-03: cluster_stop ✓ (Plan 03)
# CLUS-04: cluster_destroy ✓ (Plan 03)
# CLUS-05: cluster_status ✓ (this plan)
# CLUS-06: cluster_handle ✓ (this plan)
# CLUS-07: idempotency ✓ (Plan 02)
```
</verification>

<success_criteria>
- cluster_status returns per-node domain state and SSH reachability with parallel SSH checks
- cluster_handle returns ClusterHandle with node descriptors, artifact_path, and kernel_version from SSH
- cluster_handle fails cleanly on non-running clusters
- All 6 cluster lifecycle tools (define, start, stop, destroy, status, handle) are registered and tested
- All Phase 2 tests pass, ruff clean
- All 7 CLUS requirements addressed
</success_criteria>

<output>
After completion, create `.planning/phases/02-core-vm-lifecycle/02-04-SUMMARY.md`
</output>
