# EnginePlugin and EngineSession Contract

Both base classes live in `simready_benchmark.engines.base`. This page documents
every method, the two supporting dataclasses, and the result-file contract that
a plugin must honor.

## EnginePlugin

One `EnginePlugin` instance is constructed per installed plugin package. The
registry instantiates the class with no arguments. The plugin owns discovery and
session lifecycle for one engine type. All six methods are abstract.

```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(self) -> str

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

### tags(self) -> List[str]

Returns the plugin-level capability tags, for example `["kit", "isaac"]`. Keep
the set disjoint across installed plugins by convention. The runner binds each
config to the plugin that discovered it, so overlapping plugin-level tags do not
break dispatch at runtime; they break human reasoning about which plugin owns a
capability. Per-config tags on individual `EngineConfig` entries can overlap
freely.

### discover(self) -> List[EngineConfig]

Returns one `EngineConfig` per installation this plugin owns. The runner calls
this with no arguments, one time per plugin at startup. An empty list is a valid
result and is not an error. Return only configurations this plugin can drive;
the runner routes a `(test, config)` pair to the plugin that produced the
config, so claiming another plugin's config breaks routing. A subclass can add
optional keyword arguments (`KitPlugin.discover(self, engines_toml_path=None)`),
but every added argument must default such that the zero-argument call behaves
identically.

### validate(self, engine_config) -> ValidationResult

Returns a `ValidationResult`. The runner calls it after `discover` and before
`start_session`. Keep it cheap and structural: confirm the executable exists,
confirm a path resolves, confirm a required environment variable is set. A
`valid=False` result blocks `start_session` for that config. Soft issues (a
driver version outside a declared range, a missing optional variable) belong in
`warnings`, which are logged but do not block launch. Do not run network probes
or process launches here; it runs often, sometimes many times per invocation.

### start_session(self, engine_config, work_dir, log_file, session_env=None) -> EngineSession

`async`. Constructs and starts one session and returns the live
`EngineSession`. Arguments:

- `engine_config`: the validated configuration for this session.
- `work_dir`: the working directory, created by the runner before the call.
- `log_file`: path where engine stdout, stderr, and structured events are
  written.
- `session_env`: extra environment variables for autonomous-mode sessions
  (`SIMREADY_WORK_POOL`, `SIMREADY_PLAN_PATH`, and related keys). Pass these
  through to the engine process so it can find the work pool, the plan, and the
  asset roots.

The framework awaits this from its event loop. Use `await asyncio.sleep(...)`
for any startup polling; never call blocking `time.sleep` here.

### stop_session(self, session) -> None

`async`. Gracefully stops a session. The runner calls it for cancellation,
timeout, or crash cleanup. A session that finishes its own assigned work exits
naturally and does not pass through `stop_session`. For a subprocess plugin this
stops log capture and force-kills the process tree.

## EngineSession

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

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

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

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

### is_alive(self) -> bool

Returns `True` if the engine is still running and responsive. Polled by the
runner's liveness monitor. A bare `process.poll() is None` check misses a hung
process that is technically alive, so prefer an activity-based check in addition.
`KitSession` treats the session as dead when no stdout has appeared for
`OUTPUT_INACTIVITY_TIMEOUT` (180 seconds), falling back to the process poll.

### cleanup(self) -> None

`async`. Releases session resources: close files, join threads, terminate
subprocesses. Called after `stop_session` and after natural exit is detected.
Hand any blocking wait or thread join to `loop.run_in_executor` so the shared
event loop stays responsive.

### get_session_id(self) -> str

Returns the unique session identifier. It must be stable for the lifetime of the
session and is used in log messages, work-pool claiming, and event attribution.
Typical format: `"<engine>_<pid>_<index>"`.

## Supporting Dataclasses

Both live in `simready_benchmark.engines.base`.

```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)


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

`EngineConfig` fields:

- `name`: the config identifier, unique within the run. For `KitPlugin` this is
  the `[kit.<instance>]` subtable name.
- `executable_path`: the engine binary or launch script for a subprocess plugin.
- `version`: the engine version string, echoed into `SIMREADY_ENGINE_VERSION`.
- `tags`: per-config capability tags, used to filter which tests this config can
  run. These can overlap across configs and plugins.
- `custom_args`: extra command-line arguments appended to the launch command.
- `session_mode`: defaults to `"batch"`.
- `driver_compatibility`: a list of dicts the plugin can consult during
  `validate` to warn about driver mismatches. `KitPlugin` reads `os`, `ranges`,
  and `message` keys from each entry.

`ValidationResult` fields: `valid` (a `False` value blocks `start_session`),
`message` (a one-line reason), and `warnings` (logged but non-blocking).

## Result-File Mirror-Layout Contract

Every session must write a `result.json` for each completed test, and the
`"asset"` field of that file must carry the orchestrator's canonical
`asset_key`. The canonical per-test path under the run output directory is

```
<output_dir>/results/<asset-dir>/.simready/runtime/<test_name>/result.json
```

The reporter aggregates results by the `"asset"` field across every plugin in
the run. A worker that writes an absolute or engine-local path into that field
makes the reporter count two assets where there is one, and the HTML report shows
the same asset twice. The orchestrator-relative `asset_key` (produced by
`simready_benchmark.core.asset_key.asset_rel_key`, forward-slash separated) prevents
that split.

For a subprocess plugin, the engine inside the process typically writes
`result.json` itself, already using the key it received with its work item. For
a host-side plugin, force the field before writing:

```python
data = dict(result) if result else {}
data.setdefault("test_name", tdef.name)
data["asset"] = asset_key
```

A plugin that skips this override can pass every test in isolation and still
corrupt a mixed-mode report.
