|
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import base64 |
| 8 | +import contextlib |
8 | 9 | from collections.abc import AsyncIterator |
| 10 | +from dataclasses import dataclass |
9 | 11 | from typing import Protocol |
| 12 | +from uuid import UUID |
10 | 13 |
|
11 | 14 | CLAUDE_RUNNER_IMAGE = "claude-runner:latest" |
12 | 15 |
|
| 16 | +# Labels stamped on every runner. ``boot_id`` identifies the API process that |
| 17 | +# spawned it, which is how a shutting-down worker tells its own containers |
| 18 | +# apart from a peer's: with several uvicorn workers sharing one Docker socket, |
| 19 | +# "every running container" is not the same set as "mine". |
| 20 | +LABEL_SESSION_ID = "helprs.session_id" |
| 21 | +LABEL_BOOT_ID = "helprs.boot_id" |
| 22 | + |
| 23 | + |
| 24 | +@dataclass(frozen=True) |
| 25 | +class RunnerContainer: |
| 26 | + """A live runner, identified by both ids the cleanup paths need.""" |
| 27 | + |
| 28 | + container_id: str |
| 29 | + session_id: UUID |
| 30 | + |
| 31 | + |
13 | 32 | CONTAINER_MEMORY_BYTES = 512 * 1024 * 1024 |
14 | 33 | CONTAINER_NANO_CPUS = 1_000_000_000 |
15 | 34 | # Generous for a git clone plus a node process, tight enough that a runaway |
@@ -54,6 +73,19 @@ async def wait_container(self, container_id: str) -> int: |
54 | 73 | """Block until the container exits; return its exit code.""" |
55 | 74 | ... |
56 | 75 |
|
| 76 | + async def container_is_running(self, container_id: str) -> bool: |
| 77 | + """Whether the container still exists and is running. |
| 78 | +
|
| 79 | + The honest answer to "is this session actually alive?", which the DB |
| 80 | + cannot give: a row says RUNNING whether its container is streaming or |
| 81 | + died with the process that started it. |
| 82 | + """ |
| 83 | + ... |
| 84 | + |
| 85 | + async def list_runners(self, *, boot_id: str) -> list[RunnerContainer]: |
| 86 | + """Live runners started by one API process, newest state from Docker.""" |
| 87 | + ... |
| 88 | + |
57 | 89 | async def close(self) -> None: ... |
58 | 90 |
|
59 | 91 |
|
@@ -96,6 +128,31 @@ async def create_container( |
96 | 128 | ) |
97 | 129 | return container.id |
98 | 130 |
|
| 131 | + async def container_is_running(self, container_id: str) -> bool: |
| 132 | + try: |
| 133 | + container = await self._docker.containers.get(container_id) |
| 134 | + state = (await container.show()).get("State") or {} |
| 135 | + except Exception: |
| 136 | + # Gone, unreachable, or never existed: for every caller here that |
| 137 | + # means "not alive", and treating an error as "still running" |
| 138 | + # would leave sessions stuck RUNNING forever. |
| 139 | + return False |
| 140 | + return bool(state.get("Running")) |
| 141 | + |
| 142 | + async def list_runners(self, *, boot_id: str) -> list[RunnerContainer]: |
| 143 | + containers = await self._docker.containers.list( |
| 144 | + filters={"label": [f"{LABEL_BOOT_ID}={boot_id}"]}, |
| 145 | + ) |
| 146 | + runners = [] |
| 147 | + for container in containers: |
| 148 | + # A runner whose session label is missing or unparseable is not |
| 149 | + # ours to reason about; skipping beats guessing which row it |
| 150 | + # belongs to and cancelling the wrong session. |
| 151 | + with contextlib.suppress(KeyError, TypeError, ValueError): |
| 152 | + labels = container["Labels"] or {} |
| 153 | + runners.append(RunnerContainer(container_id=container.id, session_id=UUID(labels[LABEL_SESSION_ID]))) |
| 154 | + return runners |
| 155 | + |
99 | 156 | async def start_container(self, container_id: str) -> None: |
100 | 157 | container = await self._docker.containers.get(container_id) |
101 | 158 | await container.start() |
|
0 commit comments