---
phase: 03-ssh-execution
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - vmcluster_mcp/exec/ssh.py
  - tests/test_ssh_client.py
autonomous: true
requirements:
  - EXEC-03

must_haves:
  truths:
    - "SSH connections are cached per (ip, port, user, key_path) tuple — no redundant handshakes"
    - "A stale connection (host rebooted) is detected and replaced transparently on next execute()"
    - "invalidate(ip) removes a cached connection so the next call opens a fresh one"
    - "close_all() tears down every cached connection cleanly"
  artifacts:
    - path: "vmcluster_mcp/exec/ssh.py"
      provides: "SSHConnectionManager with caching and transparent reconnect"
      exports: ["SSHConnectionManager"]
      min_lines: 80
    - path: "tests/test_ssh_client.py"
      provides: "Unit tests for SSHConnectionManager"
      min_lines: 50
  key_links:
    - from: "vmcluster_mcp/exec/ssh.py"
      to: "asyncssh"
      via: "asyncssh.connect for connection lifecycle"
      pattern: "asyncssh\\.connect"
    - from: "vmcluster_mcp/exec/ssh.py"
      to: "vmcluster_mcp/models.py"
      via: "ExecResult for structured output"
      pattern: "from vmcluster_mcp\\.models import ExecResult"
---

<objective>
Create the SSH connection manager that caches asyncssh connections per-node and transparently reconnects after stale connections (VM reboot). This is the foundation for all Phase 3 tools.

Purpose: EXEC-03 requires transparent reconnect — the SSH layer must detect stale connections and re-establish them without caller intervention. A connection cache avoids redundant handshakes for sequential commands on the same node.

Output: `vmcluster_mcp/exec/ssh.py` with `SSHConnectionManager` class + unit 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/REQUIREMENTS.md
@.planning/phases/02-core-vm-lifecycle/02-03-SUMMARY.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From vmcluster_mcp/models.py:
```python
class ExecResult(BaseModel):
    exit_code: int
    stdout: str
    stderr: str
    duration_s: float
    timed_out: bool
```

From vmcluster_mcp/cluster/__init__.py (existing asyncssh pattern):
```python
async def _wait_for_ssh(ip: str, port: int, user: str, key_path: str, timeout_s: int) -> tuple[bool, float]:
    """Poll SSH until the node responds or the timeout expires."""
    start = time.monotonic()
    deadline = start + timeout_s
    while time.monotonic() < deadline:
        try:
            async with asyncssh.connect(
                ip, port=port, username=user, client_keys=[key_path],
                known_hosts=None, login_timeout=5,
            ) as conn:
                await conn.run("echo ok", check=True)
                return True, time.monotonic() - start
        except (OSError, asyncssh.Error):
            await asyncio.sleep(2)
    return False, time.monotonic() - start
```

From vmcluster_mcp/config.py:
```python
class Config(BaseModel):
    ssh_key_path: str = "/etc/vmcluster/ssh/vmcluster_id_ed25519"
    ssh_user: str = "root"
```

From vmcluster_mcp/topology/schema.py:
```python
class SshSpec(BaseModel):
    key_path: str
    user: str = "root"
    connect_timeout_s: int = 30
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create SSHConnectionManager with caching and transparent reconnect</name>
  <files>vmcluster_mcp/exec/ssh.py, tests/test_ssh_client.py</files>
  <read_first>
    - vmcluster_mcp/models.py (ExecResult definition at lines 64-70)
    - vmcluster_mcp/cluster/__init__.py (existing asyncssh usage pattern — _wait_for_ssh at line 202)
    - vmcluster_mcp/config.py (SSH config fields)
    - pyproject.toml (asyncssh dependency already present)
  </read_first>
  <behavior>
    - Test 1: execute() on a fresh manager opens a new asyncssh connection and returns ExecResult with exit_code, stdout, stderr, duration_s
    - Test 2: execute() called twice for the same (ip, port, user, key_path) reuses the cached connection (only one asyncssh.connect call)
    - Test 3: execute() for different hosts opens separate connections
    - Test 4: When cached connection raises asyncssh.ConnectionLost or asyncssh.DisconnectError, execute() invalidates the stale entry and retries with a fresh connection — second attempt succeeds transparently
    - Test 5: When retry also fails, execute() returns ExecResult with timed_out=False and the SSH error in stderr
    - Test 6: invalidate(ip) removes the cached connection; next execute() opens a new one
    - Test 7: close_all() closes all cached connections
    - Test 8: Command timeout — execute() with timeout_s that expires returns ExecResult with timed_out=True
    - Test 9: Non-zero exit code — command returns exit_code != 0, stdout, stderr correctly populated
  </behavior>
  <action>
    Create `vmcluster_mcp/exec/ssh.py` with:

    ```python
    class SSHConnectionManager:
        """Async SSH connection manager with per-node caching and transparent reconnect."""
    ```

    **Constructor:** `__init__(self)` — initializes `_connections: dict[tuple[str, int, str, str], asyncssh.SSHClientConnection]` (cache keyed by (ip, port, user, key_path)).

    **`async def execute(self, ip, port, user, key_path, command, timeout_s=30) -> ExecResult`:**
    1. Build cache key `(ip, port, user, key_path)`
    2. If key in `_connections`, try running command on cached connection
    3. On `asyncssh.ConnectionLost`, `asyncssh.DisconnectError`, or `OSError`: invalidate cached entry, open new connection, retry command once
    4. If no cached connection, open new via `asyncssh.connect(ip, port=port, username=user, client_keys=[key_path], known_hosts=None, login_timeout=10)`
    5. Cache the new connection
    6. Run `conn.run(command, timeout=timeout_s)` — do NOT pass `check=True` (we want to capture non-zero exit codes)
    7. On `asyncssh.TimeoutError`: return `ExecResult(exit_code=-1, stdout="", stderr="Command timed out", duration_s=elapsed, timed_out=True)`
    8. On success: return `ExecResult(exit_code=result.exit_status, stdout=result.stdout or "", stderr=result.stderr or "", duration_s=elapsed, timed_out=False)`
    9. On connection failure (both attempts): return `ExecResult(exit_code=-1, stdout="", stderr=str(error), duration_s=elapsed, timed_out=False)`

    **`async def invalidate(self, ip: str) -> None`:** Remove all cache entries matching `ip` (any port/user/key combo). Close the connection gracefully.

    **`async def close_all(self) -> None`:** Close all cached connections, clear the dict.

    Module must start with `from __future__ import annotations`. Use `logging.getLogger("vmcluster_mcp.exec.ssh")`.

    For time measurement: use `time.monotonic()` before and after command execution to populate `duration_s`.
  </action>
  <acceptance_criteria>
    - vmcluster_mcp/exec/ssh.py contains `class SSHConnectionManager`
    - vmcluster_mcp/exec/ssh.py contains `async def execute(self` with parameters ip, port, user, key_path, command, timeout_s
    - vmcluster_mcp/exec/ssh.py contains `async def invalidate(self, ip`
    - vmcluster_mcp/exec/ssh.py contains `async def close_all(self`
    - vmcluster_mcp/exec/ssh.py contains `from vmcluster_mcp.models import ExecResult`
    - vmcluster_mcp/exec/ssh.py contains `_connections: dict`
    - vmcluster_mcp/exec/ssh.py contains `asyncssh.connect(`
    - vmcluster_mcp/exec/ssh.py contains `asyncssh.ConnectionLost` or `asyncssh.DisconnectError` in except clause
    - vmcluster_mcp/exec/ssh.py contains `known_hosts=None`
    - vmcluster_mcp/exec/ssh.py contains `time.monotonic()`
    - tests/test_ssh_client.py exists with at least 8 test functions
    - `pytest tests/test_ssh_client.py -x` exits 0
    - `ruff check vmcluster_mcp/exec/ssh.py` exits 0
  </acceptance_criteria>
  <verify>
    <automated>pytest tests/test_ssh_client.py -x && ruff check vmcluster_mcp/exec/ssh.py</automated>
  </verify>
  <done>SSHConnectionManager caches connections per-node, transparently reconnects on stale connections, and returns structured ExecResult for all outcomes (success, error, timeout). All tests pass.</done>
</task>

</tasks>

<verification>
- `pytest tests/test_ssh_client.py -x` passes all tests
- `ruff check vmcluster_mcp/exec/ssh.py` shows no lint errors
- `grep -c "class SSHConnectionManager" vmcluster_mcp/exec/ssh.py` returns 1
- `grep "async def execute" vmcluster_mcp/exec/ssh.py` shows the method exists
- `grep "asyncssh.ConnectionLost\|asyncssh.DisconnectError" vmcluster_mcp/exec/ssh.py` confirms reconnect handling
</verification>

<success_criteria>
SSHConnectionManager in exec/ssh.py caches asyncssh connections, transparently retries on stale connections (EXEC-03 foundation), and returns ExecResult with all fields populated. Unit tests cover caching, reconnect, timeout, and error paths.
</success_criteria>

<output>
After completion, create `.planning/phases/03-ssh-execution/03-01-SUMMARY.md`
</output>
