# The EnginePlugin and EngineSession Contract

Authoritative source: `simready_benchmark/engines/base.py`. Two abstract base
classes and two supporting dataclasses define the entire plugin surface. This
reference walks each method and field. Every signature below matches the source.

## EnginePlugin

One `EnginePlugin` instance is constructed per installed plugin package, with no
arguments. It owns discovery and session lifecycle for one engine type. All six
methods are abstract, so a subclass must implement every one.

```python
class EnginePlugin(ABC):
    @abstractmethod
    def name(self) -> str: ...

    @abstractmethod
    def tags(self) -> List[str]: ...

    @abstractmethod
    def discover(self) -> List[EngineConfig]: ...

    @abstractmethod
    def validate(self, engine_config: EngineConfig) -> ValidationResult: ...

    @abstractmethod
    async def start_session(
        self,
        engine_config: EngineConfig,
        work_dir: str,
        log_file: str,
        session_env: Optional[Dict[str, str]] = None,
    ) -> "EngineSession": ...

    @abstractmethod
    async def stop_session(self, session: "EngineSession") -> None: ...
```

### name

Returns the plugin identifier, for example `"kit"` or `"nvcf"`. Used in log
messages and session IDs. Keep it unique across installed plugins.

### tags

Returns the plugin's capability tags as a `List[str]`. The runner uses these for
plugin-to-config routing. Keep plugin-level tags disjoint across installed
plugins by convention. `NvcfPlugin.tags()` returns `["nvcf"]` only; the `kit`
capability rides on each `EngineConfig` it discovers, not on the plugin. Adding
`kit` at the plugin level would mis-route config-to-plugin matching.

### discover

Returns one `EngineConfig` per installation this plugin owns. An empty list is a
valid result and is not an error. The framework calls `discover()` one time per
plugin during startup, with no arguments. A subclass can add optional keyword
arguments (the bundled plugins extend the signature to
`discover(self, engines_toml_path=None)`), but any added argument must default
to a value that reproduces the zero-argument result.

Each plugin returns only configurations it can itself drive. The runner routes a
`(test, config)` pair to the plugin that produced the config, so returning
another plugin's configuration breaks routing.

### validate

Returns a `ValidationResult` for one `EngineConfig`. The runner calls `validate`
after `discover` and before `start_session`. Keep it a cheap structural check,
not a network probe or a process launch; it can run many times per invocation.
Set `valid=False` with a one-line `message` for a structurally broken config (a
missing required field, an unparseable path, or a URL with no scheme or
netloc). Add a string to `warnings` for a soft issue a session can still live
with (for example a missing API-key environment variable on a remote endpoint).
A `valid=False` result blocks `start_session` for that config; warnings only
surface in logs.

### start_session

`async`. Constructs and starts one `EngineSession`, then returns it. Arguments:

- `engine_config`: the validated configuration for this session.
- `work_dir`: the per-session working directory. The runner creates it before
  calling.
- `log_file`: path for engine output and structured events.
- `session_env`: optional extra environment variables for autonomous-mode
  sessions, including `SIMREADY_WORK_POOL`, `SIMREADY_PLAN_PATH`,
  `SIMREADY_OUTPUT_DIR`, and `SIMREADY_CONTENT_ROOT`. Pass these through to the
  worker so it can find the work pool, the plan, and the asset roots.

The method is awaited from the framework event loop. Use `await
asyncio.sleep(...)` for any startup polling; never call blocking `time.sleep`
here.

### stop_session

`async`. Gracefully stops a session. The runner calls it for cancellation,
timeout, or crash cleanup. A session that finishes its own work exits naturally
without going through `stop_session`. A transport-driven plugin typically
delegates this to `session.cleanup()` because there is no local subprocess to
kill.

## EngineSession

One `EngineSession` instance exists per concurrent engine session. The runner
holds the handle until the session ends or is detected dead.

```python
class EngineSession(ABC):
    @abstractmethod
    def is_alive(self) -> bool: ...

    @abstractmethod
    async def cleanup(self) -> None: ...

    @abstractmethod
    def get_session_id(self) -> str: ...
```

Three methods are abstract. The session claims tests from the shared work pool
autonomously; the runner does not call `load_asset` or `execute_test`.

### is_alive

Returns `True` if the engine is still running and responsive. The runner's
liveness monitor polls it. A bare status poll misses the "alive but hung"
failure mode, so combine the raw check with an activity threshold. A transport
session combines `thread.is_alive()` with the recency of the last emitted event;
periodic `idle_keepalive` emissions keep a long but healthy session from
tripping the threshold. `NvcfSession` uses a 300-second inactivity timeout.

### cleanup

`async`. Releases session resources: close files, join threads, terminate
subprocesses, close HTTP clients. Called after `stop_session` and after natural
session exit is detected. Because it is awaited from the event loop, never block
inside it. A transport session sets a stop event and joins the worker thread
through `run_in_executor` so the loop stays responsive:

```python
async def cleanup(self):
    self._stop_event.set()
    thread = self._thread
    if thread is not None:
        await asyncio.get_event_loop().run_in_executor(None, thread.join, _THREAD_JOIN_TIMEOUT)
```

### get_session_id

Returns the unique session identifier. Must be stable for the lifetime of the
session. Used in log messages, work-pool claiming, and event attribution. The
typical format is `"<engine>_<pid>_<index>"`.

### drain_events (convention, not on the ABC)

Not part of the abstract contract, but every bundled session implements it so
the runner can poll buffered events the same way for every engine. A transport
session appends structured event dicts to a list under a lock and exposes them
through `drain_events`, which returns and clears the buffer:

```python
def drain_events(self):
    with self._event_lock:
        events = list(self._event_list)
        self._event_list.clear()
    return events
```

## EngineConfig

```python
@dataclass
class EngineConfig:
    name: str
    executable_path: str
    version: str
    tags: List[str] = field(default_factory=list)
    custom_args: List[str] = field(default_factory=list)
    session_mode: str = "batch"
    driver_compatibility: List[Dict[str, Any]] = field(default_factory=list)
```

| Field | Meaning |
|---|---|
| `name` | Config identifier, unique within the plugin. The plugin keys its private side tables by this. |
| `executable_path` | Path to the local engine binary for a subprocess plugin. A transport-driven plugin leaves it the empty string and carries the endpoint URL in a private side table keyed by `name`. |
| `version` | Engine version string. A transport plugin uses a label such as `"acme-remote"`. Logged and serialized with results; not used for claim compatibility. |
| `tags` | Per-config capability tags. These can overlap across configs freely. A remote endpoint that can run kit tests carries `["nvcf", "kit"]` here even though the plugin-level `tags()` is `["nvcf"]`. |
| `custom_args` | Extra engine arguments. Unused by a transport plugin. |
| `session_mode` | Defaults to `"batch"`. |
| `driver_compatibility` | Driver-range metadata for local engines. Unused by a transport plugin. |

The framework logs and serializes `EngineConfig`, so do not store secrets or
transport details (URLs, tokens) on it. Keep those in a plugin-private
dictionary keyed by `EngineConfig.name`, populated in `discover()` and read in
`start_session()` and `validate()`.

## ValidationResult

```python
@dataclass
class ValidationResult:
    valid: bool
    message: str = ""
    warnings: List[str] = field(default_factory=list)
```

| Field | Meaning |
|---|---|
| `valid` | `False` blocks `start_session` for the config. `True` allows launch. |
| `message` | A one-line explanation, set on a refusal. |
| `warnings` | Soft issues that surface in logs but do not block launch, for example a missing API-key environment variable. |

## Discovery and Registration

The registry finds plugins through the `simready_benchmark.engines` entry-point
group (`ENTRY_POINT_GROUP = "simready_benchmark.engines"` in
`simready_benchmark/engines/registry.py`). The registration value is
`module:ClassName`, with the `:ClassName` suffix:

```toml
[project.entry-points."simready_benchmark.engines"]
acme_cloud = "acme_cloud_plugin.plugin:AcmeCloudPlugin"
```

`discover_engine_plugins` calls `entry_points(group="simready_benchmark.engines")`,
loads each entry point, and instantiates the class with no arguments. A loaded
object that is not an `EnginePlugin` instance is skipped with a warning, and an
entry point that fails to load is logged and skipped. The returned mapping is
keyed by the entry-point label (the left side of the assignment).

The discoverer-owns-config rule is structural: the registry builds a
config-name to plugin map so the runner can route each `(test, config)` pair
back to the plugin that produced the config. Returning another plugin's
configuration from `discover()` corrupts that map.
