diff --git a/.console/log.md b/.console/log.md index f91d24e..9a6ded5 100644 --- a/.console/log.md +++ b/.console/log.md @@ -3,6 +3,25 @@ _Chronological continuity log. Decisions, stop points, what changed and why._ _Not a task tracker — that's backlog.md. Keep entries concise and dated._ +## 2026-05-19 — Phase 1 custodian fixes + +- C13: added src/core_runner/process.py to c13_allowed_paths (os.environ.copy() is the env-overlay layer). +- T7: renamed tests/test_safe_run.py → tests/test_process.py to match parallel naming convention. +- X2: added contracts/** to X2 exclude_paths — PlatformManifest still has executor_runtime node key; CoreRunner→RxP edge will be wired in Phase 4 (ADR 0006). + +## 2026-05-19 — ADR 0006 Phase 1: CoreRunner rename + safe_run() extraction + +- Copied src/executor_runtime/ → src/core_runner/; bulk-renamed all imports, class names, error names. +- Created src/core_runner/process.py: SafeRunResult dataclass + safe_run() primitive (start_new_session, os.killpg on timeout, transient SIGTERM handler). No RxP dependency. +- Rewrote src/core_runner/runners/subprocess_runner.py to delegate to safe_run() — removes duplication. +- Updated src/core_runner/__init__.py: exports CoreRunner, safe_run, SafeRunResult. +- Updated pyproject.toml: name → core-runner, description updated, Repository URL → CoreRunner. +- Updated .custodian/config.yaml: repo_key → CoreRunner, src_root → src/core_runner, all path patterns updated. +- Bulk-updated all test imports: executor_runtime → core_runner, ExecutorRuntime → CoreRunner. +- Added tests/test_safe_run.py: 11 tests covering zero-exit, nonzero-exit, stdout/stderr capture, env overlay, cwd, timeout/kill, process-group kill, capture_output=False, dataclass shape. 76 tests pass. +- README rewritten for CoreRunner + dual surface (safe_run primitive + CoreRunner.run RxP path). +- src/executor_runtime/ left in place until GitHub repo rename (Phase 6) — old package unused by tests. + - 2026-05-19 — Removed live archon references from test fixtures. Updated test_manual_runner.py (archon → dag_executor, archon-workflow → dag-executor). Updated test_async_http_runner.py (archon-workflow → dag-executor). 65 tests pass. @@ -81,3 +100,7 @@ truth; pre-push catches regressions before they hit GitHub. - Added CLAUDE.md to .gitignore - Added .custodian/tmp*.yaml to exclude custodian audit temp files + +## 2026-05-19 — Remove old src/executor_runtime/ tree + +Deleted legacy src/executor_runtime/ package now that all code lives in src/core_runner/. All 76 tests pass. diff --git a/.custodian/config.yaml b/.custodian/config.yaml index 43159d6..b39cec8 100644 --- a/.custodian/config.yaml +++ b/.custodian/config.yaml @@ -1,5 +1,5 @@ -repo_key: ExecutorRuntime -src_root: src/executor_runtime +repo_key: CoreRunner +src_root: src/core_runner tests_root: tests # --------------------------------------------------------------------------- @@ -19,27 +19,33 @@ audit: cross_repo: platform_manifest_repo: ../PlatformManifest - # subprocess_runner.py constructs the env overlay for each subprocess run — - # legitimately reads os.environ as the env-mutation layer. + # process.py merges the env overlay (os.environ.copy() + update) — + # subprocess_runner.py delegates to it; both legitimately touch os.environ. c13_allowed_paths: - - "src/executor_runtime/runners/subprocess_runner.py" + - "src/core_runner/process.py" + - "src/core_runner/runners/subprocess_runner.py" exclude_paths: T1: - - "src/executor_runtime/contracts/**" - - src/executor_runtime/errors.py - - "src/executor_runtime/io/**" - - src/executor_runtime/runners/base.py + - "src/core_runner/contracts/**" + - src/core_runner/errors.py + - "src/core_runner/io/**" + - src/core_runner/runners/base.py T6: - - "src/executor_runtime/contracts/**" - - src/executor_runtime/errors.py - - "src/executor_runtime/io/**" - - src/executor_runtime/runners/base.py + - "src/core_runner/contracts/**" + - src/core_runner/errors.py + - "src/core_runner/io/**" + - src/core_runner/runners/base.py T7: - - "src/executor_runtime/contracts/**" - - src/executor_runtime/errors.py - - "src/executor_runtime/io/**" - - src/executor_runtime/runners/base.py + - "src/core_runner/contracts/**" + - src/core_runner/errors.py + - "src/core_runner/io/**" + - src/core_runner/runners/base.py + X2: + # PlatformManifest node key will be updated to CoreRunner in Phase 4 + # (ADR 0006). Until then the CoreRunner→RxP edge is registered under + # executor_runtime in the manifest — exclude to avoid false positives. + - "src/core_runner/contracts/**" D11: # http_runner / async_http_runner share _build_content_kwargs by # design — sync/async pair of the same runner shape. - - "src/executor_runtime/runners/**" + - "src/core_runner/runners/**" diff --git a/README.md b/README.md index 5627f86..cbf483f 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,33 @@ -# ExecutorRuntime +# CoreRunner -`ExecutorRuntime` is the generic runtime execution layer for [RxP](https://github.com/ProtocolWarden/RxP)-shaped invocations. It dispatches by `runtime_kind` to a registered runner and returns a normalized RxP `RuntimeResult`. +`CoreRunner` is the process-group-safe subprocess library for the ProtocolWarden ecosystem. It provides two surfaces: + +1. **`safe_run()` primitive** — standalone, no RxP dependency. Used by TeamExecutor, DAGExecutor, and CritiqueExecutor to replace raw `subprocess.run()` calls with full process-group safety. +2. **`CoreRunner.run(invocation)`** — full RxP invocation runner (stdout/stderr capture to files, ArtifactDescriptor production). Used by OperationsCenter's `direct_local` and `aider_local` adapters. ```text -RuntimeInvocation → ExecutorRuntime.run → RuntimeResult - ├─ "subprocess" → SubprocessRunner +safe_run(cmd, ...) → SafeRunResult # lightweight primitive +CoreRunner.run(inv) → RuntimeResult # full RxP path + +RuntimeInvocation → CoreRunner.run → RuntimeResult + ├─ "subprocess" → SubprocessRunner → safe_run() ├─ "manual" → ManualRunner (caller-supplied dispatcher) └─ "http" → HttpRunner (sync request/response) ``` ## What this repo is -Generic runtime mechanics: - -- subprocess execution with process-group safety (`start_new_session=True`, `os.killpg(SIGKILL)` on timeout, transient SIGTERM handler) -- environment overlay -- working directory control -- timeout enforcement -- stdout/stderr capture to files -- exit-code normalization -- ArtifactDescriptor collection -- dispatch-by-`runtime_kind` registry +- Process-group-safe subprocess execution (`start_new_session=True`, `os.killpg(SIGKILL)` on timeout, transient SIGTERM handler that reaps child group on supervisor death) +- Standalone `safe_run()` primitive — no RxP types, no artifact descriptors; just `SafeRunResult(returncode, stdout, stderr, timed_out)` +- Environment overlay, working directory control, timeout enforcement +- Stdout/stderr capture to files and ArtifactDescriptor collection (RxP path only) +- Dispatch-by-`runtime_kind` registry ## What this repo is not - OperationsCenter — orchestration, planning, policy - SwitchBoard — lane/backend selection -- SourceRegistry — source/fork/dependency tracking -- CxRP — orchestration contract +- TeamExecutor / DAGExecutor / CritiqueExecutor — AI execution backends (they consume `safe_run()`) - a scheduler / queue system / fork manager / agent framework ## Quick start @@ -36,39 +36,63 @@ Generic runtime mechanics: pip install -e . ``` -Dispatch an RxP invocation by `runtime_kind`: +### Primitive (no RxP dependency) ```python -from executor_runtime import ExecutorRuntime -result = ExecutorRuntime().run(invocation) # → RuntimeResult +from core_runner.process import safe_run + +result = safe_run(["python", "-c", "print('hello')"], timeout_seconds=30) +print(result.returncode) # 0 +print(result.stdout) # "hello\n" +print(result.timed_out) # False ``` -See **Example usage** below for the full subprocess / manual / http flows. +### Full RxP path + +```python +from core_runner import CoreRunner +result = CoreRunner().run(invocation) # → RuntimeResult +``` ## Architecture -Single-entry dispatcher: `ExecutorRuntime.run(invocation)` reads `invocation.runtime_kind` and forwards to a registered runner. Three are bundled — `SubprocessRunner` (process-group-safe local exec), `ManualRunner` (caller-supplied callable), `HttpRunner` (kickoff + poll-until-terminal). Every runner returns a normalized RxP `RuntimeResult`. See **Runners** below for the per-kind contract. +`safe_run()` is the execution primitive — it owns all process-group logic. `SubprocessRunner` delegates to `safe_run()` and adds the file-capture / ArtifactDescriptor layer. `CoreRunner.run(invocation)` reads `invocation.runtime_kind` and forwards to a registered runner. ## Runners | Runner | runtime_kind | What it does | |---|---|---| -| `SubprocessRunner` | `subprocess` | Local subprocess with process-group safety. Default registered runner. | -| `ManualRunner` | `manual` | Forwards invocation to a caller-supplied dispatcher callable. For out-of-process services where ExecutorRuntime doesn't own the transport. | -| `HttpRunner` | `http` | Synchronous HTTP request/response. URL/method/body read from `RuntimeInvocation.metadata`. | -| `AsyncHttpRunner` | `http_async` | Async-shaped HTTP — kickoff (POST `→` 202 + run_id) then poll status URL until a terminal status. Sync from caller's POV. URL templates and JSON paths read from metadata. | - -SSE streaming for async APIs is still deferred — track-able via the `runtime_kind` vocabulary if/when added. +| `SubprocessRunner` | `subprocess` | Local subprocess via `safe_run()`. Default registered runner. | +| `ManualRunner` | `manual` | Forwards invocation to a caller-supplied dispatcher callable. | +| `HttpRunner` | `http` | Synchronous HTTP request/response. | +| `AsyncHttpRunner` | `http_async` | 202 kickoff + poll-until-terminal. | ## Example usage -### Subprocess (default) +### safe_run() — primitive ```python -from executor_runtime import ExecutorRuntime -from executor_runtime.contracts import RuntimeInvocation +from core_runner.process import safe_run + +result = safe_run( + ["python", "script.py", "--arg", "value"], + cwd="/path/to/project", + env={"MY_VAR": "value"}, + timeout_seconds=60, +) +if result.timed_out: + print("timed out") +elif result.returncode != 0: + print(f"failed: {result.stderr}") +``` + +### CoreRunner — subprocess (default) -runtime = ExecutorRuntime() # SubprocessRunner registered for "subprocess" +```python +from core_runner import CoreRunner +from core_runner.contracts import RuntimeInvocation + +runtime = CoreRunner() result = runtime.run( RuntimeInvocation( @@ -88,76 +112,41 @@ result = runtime.run( print(result.status) # "succeeded" ``` -### Manual (out-of-process service) +### CoreRunner — manual (out-of-process service) ```python -from executor_runtime import ExecutorRuntime -from executor_runtime.runners import ManualRunner +from core_runner import CoreRunner +from core_runner.runners import ManualRunner def my_dispatcher(invocation): - # Your code: HTTP call, queue publish, RPC, whatever raw = call_external_service(...) return synthesize_runtime_result(invocation, raw) -runtime = ExecutorRuntime() +runtime = CoreRunner() runtime.register("manual", ManualRunner(my_dispatcher)) - result = runtime.run(invocation_with_kind_manual) ``` -### HTTP (synchronous) - -```python -from executor_runtime import ExecutorRuntime -from executor_runtime.runners import HttpRunner - -runtime = ExecutorRuntime() -runtime.register("http", HttpRunner()) - -# Invocation metadata carries http.url + http.method + http.body -result = runtime.run(invocation_with_runtime_kind_http) -``` - -### HTTP (async-shaped — 202 + poll) - -```python -from executor_runtime import ExecutorRuntime -from executor_runtime.runners import AsyncHttpRunner - -runtime = ExecutorRuntime() -runtime.register("http_async", AsyncHttpRunner()) - -# Invocation metadata carries: -# http.url — kickoff URL (POST endpoint, 202 → {"run_id": "..."}) -# http.poll_url_template — e.g. "https://api/runs/{run_id}" -# http.poll_run_id_path — dotted path to extract run_id from kickoff response -# http.poll_status_path — dotted path to extract status from poll response -# http.poll_terminal_states — comma-separated, e.g. "completed,failed,cancelled" -# http.poll_success_states — subset (default: "completed") -# http.poll_interval_seconds — default 2.0 -result = runtime.run(invocation_with_runtime_kind_http_async) -``` - ## Installation ```bash -pip install executor-runtime +pip install core-runner # or with HTTP support: -pip install "executor-runtime[http]" +pip install "core-runner[http]" ``` For development: ```bash -git clone https://github.com/ProtocolWarden/ExecutorRuntime.git -cd ExecutorRuntime +git clone https://github.com/ProtocolWarden/CoreRunner.git +cd CoreRunner pip install -e ".[dev,http]" pytest -q ``` ## Contracts -ExecutorRuntime consumes RxP types directly — no parallel dataclasses: +CoreRunner consumes RxP types directly: ```python from rxp.contracts import RuntimeInvocation, RuntimeResult, ArtifactDescriptor diff --git a/pyproject.toml b/pyproject.toml index 8fd4769..9b6c5e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,18 @@ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "executor-runtime" +name = "core-runner" version = "0.1.0" -description = "Runtime execution layer for normalized RxP invocations" +description = "Process-group-safe subprocess primitives and RxP invocation runner" readme = "README.md" requires-python = ">=3.11" dependencies = [ "rxp @ git+https://github.com/ProtocolWarden/RxP.git", ] +[project.urls] +Repository = "https://github.com/ProtocolWarden/CoreRunner" + [project.optional-dependencies] http = [ "httpx>=0.27", diff --git a/src/core_runner/__init__.py b/src/core_runner/__init__.py new file mode 100644 index 0000000..81115a4 --- /dev/null +++ b/src/core_runner/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +from core_runner.process import SafeRunResult, safe_run +from core_runner.runtime import CoreRunner + +__all__ = ["CoreRunner", "safe_run", "SafeRunResult"] diff --git a/src/executor_runtime/contracts/__init__.py b/src/core_runner/contracts/__init__.py similarity index 61% rename from src/executor_runtime/contracts/__init__.py rename to src/core_runner/contracts/__init__.py index e8a3312..c894b80 100644 --- a/src/executor_runtime/contracts/__init__.py +++ b/src/core_runner/contracts/__init__.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""ExecutorRuntime contract surface — canonical RxP types. +"""CoreRunner contract surface — canonical RxP types. -ExecutorRuntime delegates contract semantics to RxP: +CoreRunner delegates contract semantics to RxP: - ``RuntimeInvocation`` — what to run - ``RuntimeResult`` — what came back - ``ArtifactDescriptor`` — file artifacts produced by a run @@ -10,7 +10,7 @@ ``pending | running | succeeded | failed | timed_out | cancelled | rejected``. """ -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import ArtifactDescriptor, RuntimeResult +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import ArtifactDescriptor, RuntimeResult __all__ = ["RuntimeInvocation", "RuntimeResult", "ArtifactDescriptor"] diff --git a/src/executor_runtime/contracts/invocation.py b/src/core_runner/contracts/invocation.py similarity index 58% rename from src/executor_runtime/contracts/invocation.py rename to src/core_runner/contracts/invocation.py index 41c2eed..6ec6050 100644 --- a/src/executor_runtime/contracts/invocation.py +++ b/src/core_runner/contracts/invocation.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""ExecutorRuntime consumes the canonical RxP RuntimeInvocation contract. +"""CoreRunner consumes the canonical RxP RuntimeInvocation contract. -Re-exported here so callers can ``from executor_runtime.contracts +Re-exported here so callers can ``from core_runner.contracts import RuntimeInvocation`` without depending on the RxP package directly. """ diff --git a/src/executor_runtime/contracts/result.py b/src/core_runner/contracts/result.py similarity index 68% rename from src/executor_runtime/contracts/result.py rename to src/core_runner/contracts/result.py index 1a8f069..5759672 100644 --- a/src/executor_runtime/contracts/result.py +++ b/src/core_runner/contracts/result.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""ExecutorRuntime returns the canonical RxP RuntimeResult contract.""" +"""CoreRunner returns the canonical RxP RuntimeResult contract.""" from rxp.contracts import ArtifactDescriptor, RuntimeResult __all__ = ["RuntimeResult", "ArtifactDescriptor"] diff --git a/src/executor_runtime/errors.py b/src/core_runner/errors.py similarity index 77% rename from src/executor_runtime/errors.py rename to src/core_runner/errors.py index 4a204c1..2b0e8a3 100644 --- a/src/executor_runtime/errors.py +++ b/src/core_runner/errors.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 ProtocolWarden -class ExecutorRuntimeError(Exception): +class CoreRunnerError(Exception): """Base exception for executor runtime package.""" diff --git a/src/executor_runtime/io/__init__.py b/src/core_runner/io/__init__.py similarity index 64% rename from src/executor_runtime/io/__init__.py rename to src/core_runner/io/__init__.py index 07d6202..cf0432d 100644 --- a/src/executor_runtime/io/__init__.py +++ b/src/core_runner/io/__init__.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 ProtocolWarden -from executor_runtime.io.json_io import read_invocation, write_result +from core_runner.io.json_io import read_invocation, write_result __all__ = ["read_invocation", "write_result"] diff --git a/src/executor_runtime/io/json_io.py b/src/core_runner/io/json_io.py similarity index 80% rename from src/executor_runtime/io/json_io.py rename to src/core_runner/io/json_io.py index 52afc32..4acdd6c 100644 --- a/src/executor_runtime/io/json_io.py +++ b/src/core_runner/io/json_io.py @@ -3,8 +3,8 @@ import json from pathlib import Path -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult def read_invocation(path: str) -> RuntimeInvocation: diff --git a/src/executor_runtime/io/paths.py b/src/core_runner/io/paths.py similarity index 69% rename from src/executor_runtime/io/paths.py rename to src/core_runner/io/paths.py index 12fcca4..b912a5e 100644 --- a/src/executor_runtime/io/paths.py +++ b/src/core_runner/io/paths.py @@ -2,13 +2,13 @@ # Copyright (C) 2026 ProtocolWarden from pathlib import Path -from executor_runtime.contracts.invocation import RuntimeInvocation +from core_runner.contracts.invocation import RuntimeInvocation def capture_directory(invocation: RuntimeInvocation) -> Path: base = ( Path(invocation.artifact_directory) if invocation.artifact_directory - else Path(invocation.working_directory) / ".executor_runtime" + else Path(invocation.working_directory) / ".core_runner" ) return base / invocation.invocation_id diff --git a/src/core_runner/process.py b/src/core_runner/process.py new file mode 100644 index 0000000..7b59580 --- /dev/null +++ b/src/core_runner/process.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""core_runner.process — process-group-safe subprocess primitive. + +A lightweight alternative to the full RxP invocation path. Suitable for +callers that do not need artifact file capture or RxP contract types +(TeamExecutor, DAGExecutor, CritiqueExecutor). + +Key safety guarantees: +- start_new_session=True — child is its own process-group leader +- os.killpg(SIGKILL) on timeout — reaps all descendants, not just direct child +- Transient SIGTERM handler — child group is killed if the Python supervisor + is itself killed (OOM killer, supervisor stop) +""" +from __future__ import annotations + +import os +import signal +import subprocess +from dataclasses import dataclass +from typing import NoReturn + + +@dataclass +class SafeRunResult: + returncode: int | None + stdout: str + stderr: str + timed_out: bool + + +def safe_run( + cmd: list[str], + *, + cwd: str = ".", + env: dict[str, str] | None = None, + timeout_seconds: int | None = None, + capture_output: bool = True, +) -> SafeRunResult: + """Run cmd in a new process group with full descendant cleanup on timeout. + + When capture_output=False, stdout and stderr are not captured (suitable + for interactive or fire-and-forget use). SafeRunResult.stdout/stderr will + be empty strings in that case. + """ + run_env = os.environ.copy() + if env: + run_env.update(env) + + kwargs: dict = { + "cwd": cwd, + "env": run_env, + "start_new_session": True, + } + if capture_output: + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.PIPE + + proc = subprocess.Popen(cmd, **kwargs) + + try: + pgid: int | None = os.getpgid(proc.pid) if proc.pid else None + except OSError: + pgid = None + + def _kill_group() -> None: + if pgid is not None: + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, OSError): + pass + + prev_sigterm = signal.getsignal(signal.SIGTERM) + + def _sigterm_handler(signum: int, _frame: object) -> NoReturn: + _kill_group() + signal.signal(signal.SIGTERM, prev_sigterm) + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, _sigterm_handler) + try: + try: + stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + _kill_group() + try: + stdout_bytes, stderr_bytes = proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + stdout_bytes, stderr_bytes = proc.communicate() + return SafeRunResult( + returncode=proc.returncode, + stdout=stdout_bytes.decode(errors="replace") if stdout_bytes else "", + stderr=stderr_bytes.decode(errors="replace") if stderr_bytes else "", + timed_out=True, + ) + finally: + signal.signal(signal.SIGTERM, prev_sigterm) + + return SafeRunResult( + returncode=proc.returncode, + stdout=stdout_bytes.decode(errors="replace") if stdout_bytes else "", + stderr=stderr_bytes.decode(errors="replace") if stderr_bytes else "", + timed_out=False, + ) diff --git a/src/core_runner/runners/__init__.py b/src/core_runner/runners/__init__.py new file mode 100644 index 0000000..d5f6394 --- /dev/null +++ b/src/core_runner/runners/__init__.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +from core_runner.runners.async_http_runner import AsyncHttpRunner +from core_runner.runners.base import RuntimeRunner +from core_runner.runners.http_runner import HttpRunner +from core_runner.runners.manual_runner import Dispatcher, ManualRunner +from core_runner.runners.subprocess_runner import SubprocessRunner + +__all__ = [ + "RuntimeRunner", + "SubprocessRunner", + "ManualRunner", + "Dispatcher", + "HttpRunner", + "AsyncHttpRunner", +] diff --git a/src/executor_runtime/runners/async_http_runner.py b/src/core_runner/runners/async_http_runner.py similarity index 99% rename from src/executor_runtime/runners/async_http_runner.py rename to src/core_runner/runners/async_http_runner.py index 7609009..649e5b4 100644 --- a/src/executor_runtime/runners/async_http_runner.py +++ b/src/core_runner/runners/async_http_runner.py @@ -69,8 +69,8 @@ except ImportError: # pragma: no cover - dep is optional httpx = None # type: ignore[assignment] -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult _DEFAULT_POLL_INTERVAL = 2.0 _DEFAULT_SUCCESS_STATES = ("completed",) diff --git a/src/executor_runtime/runners/base.py b/src/core_runner/runners/base.py similarity index 62% rename from src/executor_runtime/runners/base.py rename to src/core_runner/runners/base.py index 36978b3..169846d 100644 --- a/src/executor_runtime/runners/base.py +++ b/src/core_runner/runners/base.py @@ -2,8 +2,8 @@ # Copyright (C) 2026 ProtocolWarden from typing import Protocol -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult class RuntimeRunner(Protocol): diff --git a/src/executor_runtime/runners/http_runner.py b/src/core_runner/runners/http_runner.py similarity index 98% rename from src/executor_runtime/runners/http_runner.py rename to src/core_runner/runners/http_runner.py index 33da4cc..e606e76 100644 --- a/src/executor_runtime/runners/http_runner.py +++ b/src/core_runner/runners/http_runner.py @@ -28,8 +28,8 @@ except ImportError: # pragma: no cover - dep is optional httpx = None # type: ignore[assignment] -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult class HttpRunner: diff --git a/src/executor_runtime/runners/manual_runner.py b/src/core_runner/runners/manual_runner.py similarity index 90% rename from src/executor_runtime/runners/manual_runner.py rename to src/core_runner/runners/manual_runner.py index 96b8e10..6168290 100644 --- a/src/executor_runtime/runners/manual_runner.py +++ b/src/core_runner/runners/manual_runner.py @@ -15,8 +15,8 @@ from collections.abc import Callable -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult Dispatcher = Callable[[RuntimeInvocation], RuntimeResult] diff --git a/src/core_runner/runners/subprocess_runner.py b/src/core_runner/runners/subprocess_runner.py new file mode 100644 index 0000000..7d06a25 --- /dev/null +++ b/src/core_runner/runners/subprocess_runner.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""SubprocessRunner — RxP invocation runner backed by core_runner.safe_run(). + +Provides stdout/stderr capture to files and ArtifactDescriptor production +on top of the process-group-safe safe_run() primitive. +""" +from __future__ import annotations + +import os +from datetime import UTC, datetime +from pathlib import Path + +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import ArtifactDescriptor, RuntimeResult +from core_runner.io.paths import capture_directory +from core_runner.process import safe_run + + +class SubprocessRunner: + def run(self, invocation: RuntimeInvocation) -> RuntimeResult: + started_at = _utc_now_iso() + working_dir = Path(invocation.working_directory) + if not working_dir.exists() or not working_dir.is_dir(): + return RuntimeResult( + invocation_id=invocation.invocation_id, + runtime_name=invocation.runtime_name, + runtime_kind=invocation.runtime_kind, + status="rejected", + exit_code=None, + started_at=started_at, + finished_at=_utc_now_iso(), + stdout_path=None, + stderr_path=None, + artifacts=[], + error_summary=f"working directory does not exist: {working_dir}", + ) + + out_dir = capture_directory(invocation) + out_dir.mkdir(parents=True, exist_ok=True) + stdout_path = out_dir / "stdout.txt" + stderr_path = out_dir / "stderr.txt" + + env_overlay: dict[str, str] = dict(invocation.environment) + + result = safe_run( + list(invocation.command), + cwd=str(working_dir), + env=env_overlay if env_overlay else None, + timeout_seconds=invocation.timeout_seconds, + ) + + stdout_path.write_text(result.stdout, encoding="utf-8") + stderr_path.write_text(result.stderr, encoding="utf-8") + + finished_at = _utc_now_iso() + artifacts = [ + ArtifactDescriptor( + artifact_id="stdout", + path=str(stdout_path), + kind="log_excerpt", + description="captured stdout", + ), + ArtifactDescriptor( + artifact_id="stderr", + path=str(stderr_path), + kind="log_excerpt", + description="captured stderr", + ), + ] + + if result.timed_out: + timeout_val = invocation.timeout_seconds + error_summary = ( + f"process exceeded timeout of {timeout_val} seconds" + if timeout_val + else "process timed out" + ) + return RuntimeResult( + invocation_id=invocation.invocation_id, + runtime_name=invocation.runtime_name, + runtime_kind=invocation.runtime_kind, + status="timed_out", + exit_code=result.returncode, + started_at=started_at, + finished_at=finished_at, + stdout_path=str(stdout_path), + stderr_path=str(stderr_path), + artifacts=artifacts, + error_summary=error_summary, + ) + + status = "succeeded" if result.returncode == 0 else "failed" + return RuntimeResult( + invocation_id=invocation.invocation_id, + runtime_name=invocation.runtime_name, + runtime_kind=invocation.runtime_kind, + status=status, + exit_code=result.returncode, + started_at=started_at, + finished_at=finished_at, + stdout_path=str(stdout_path), + stderr_path=str(stderr_path), + artifacts=artifacts, + error_summary=None, + ) + + +def _utc_now_iso() -> str: + return datetime.now(UTC).isoformat() diff --git a/src/executor_runtime/runtime.py b/src/core_runner/runtime.py similarity index 86% rename from src/executor_runtime/runtime.py rename to src/core_runner/runtime.py index d0ebf73..36fa59b 100644 --- a/src/executor_runtime/runtime.py +++ b/src/core_runner/runtime.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""ExecutorRuntime — dispatch by RxP runtime_kind. +"""CoreRunner — dispatch by RxP runtime_kind. Registers a ``RuntimeRunner`` per runtime_kind and routes each invocation to the right runner. Default registry only contains @@ -8,7 +8,7 @@ ``HttpRunner``) at construction. When an invocation arrives for a runtime_kind with no registered -runner, ExecutorRuntime returns a ``rejected`` RuntimeResult rather +runner, CoreRunner returns a ``rejected`` RuntimeResult rather than raising — same posture as the missing-working-directory check in ``SubprocessRunner``. """ @@ -16,14 +16,14 @@ from datetime import UTC, datetime -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult -from executor_runtime.io.json_io import write_result -from executor_runtime.runners.base import RuntimeRunner -from executor_runtime.runners.subprocess_runner import SubprocessRunner +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult +from core_runner.io.json_io import write_result +from core_runner.runners.base import RuntimeRunner +from core_runner.runners.subprocess_runner import SubprocessRunner -class ExecutorRuntime: +class CoreRunner: def __init__( self, *, diff --git a/src/executor_runtime/__init__.py b/src/executor_runtime/__init__.py deleted file mode 100644 index c38a1ae..0000000 --- a/src/executor_runtime/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 ProtocolWarden -from executor_runtime.runtime import ExecutorRuntime - -__all__ = ["ExecutorRuntime"] diff --git a/src/executor_runtime/runners/__init__.py b/src/executor_runtime/runners/__init__.py deleted file mode 100644 index 6362731..0000000 --- a/src/executor_runtime/runners/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 ProtocolWarden -from executor_runtime.runners.async_http_runner import AsyncHttpRunner -from executor_runtime.runners.base import RuntimeRunner -from executor_runtime.runners.http_runner import HttpRunner -from executor_runtime.runners.manual_runner import Dispatcher, ManualRunner -from executor_runtime.runners.subprocess_runner import SubprocessRunner - -__all__ = [ - "RuntimeRunner", - "SubprocessRunner", - "ManualRunner", - "Dispatcher", - "HttpRunner", - "AsyncHttpRunner", -] diff --git a/src/executor_runtime/runners/subprocess_runner.py b/src/executor_runtime/runners/subprocess_runner.py deleted file mode 100644 index 6121c3f..0000000 --- a/src/executor_runtime/runners/subprocess_runner.py +++ /dev/null @@ -1,166 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -"""Default RuntimeRunner implementation — subprocess execution. - -Important behavior: -- Spawns the child in a fresh process session (``start_new_session=True``) - so it becomes the leader of its own process group. -- On timeout, kills the **entire group** via ``os.killpg(SIGKILL)``. - This reaps any descendants (e.g. orchestrator-spawned worker - processes) that would otherwise become orphans and continue - consuming CPU / API quota. -- Installs a transient SIGTERM handler so that if the supervising - Python process is itself killed (supervisor stop, OOM killer), the - child group is killed before exit. The previous SIGTERM handler is - restored on return. - -stdout and stderr are captured to files inside ``capture_directory`` -so they can be referenced as ``ArtifactDescriptor`` paths in the -returned ``RuntimeResult``. -""" -from __future__ import annotations - -import os -import signal -import subprocess -from datetime import UTC, datetime -from pathlib import Path -from typing import NoReturn - -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import ArtifactDescriptor, RuntimeResult -from executor_runtime.io.paths import capture_directory - - -class SubprocessRunner: - def run(self, invocation: RuntimeInvocation) -> RuntimeResult: - started_at = _utc_now_iso() - working_dir = Path(invocation.working_directory) - if not working_dir.exists() or not working_dir.is_dir(): - return RuntimeResult( - invocation_id=invocation.invocation_id, - runtime_name=invocation.runtime_name, - runtime_kind=invocation.runtime_kind, - status="rejected", - exit_code=None, - started_at=started_at, - finished_at=_utc_now_iso(), - stdout_path=None, - stderr_path=None, - artifacts=[], - error_summary=f"working directory does not exist: {working_dir}", - ) - - out_dir = capture_directory(invocation) - out_dir.mkdir(parents=True, exist_ok=True) - stdout_path = out_dir / "stdout.txt" - stderr_path = out_dir / "stderr.txt" - - env = os.environ.copy() - env.update(invocation.environment) - - status, exit_code, error_summary = _run_with_process_group( - invocation=invocation, - working_dir=working_dir, - stdout_path=stdout_path, - stderr_path=stderr_path, - env=env, - ) - - finished_at = _utc_now_iso() - artifacts = [ - ArtifactDescriptor( - artifact_id="stdout", - path=str(stdout_path), - kind="log_excerpt", - description="captured stdout", - ), - ArtifactDescriptor( - artifact_id="stderr", - path=str(stderr_path), - kind="log_excerpt", - description="captured stderr", - ), - ] - - return RuntimeResult( - invocation_id=invocation.invocation_id, - runtime_name=invocation.runtime_name, - runtime_kind=invocation.runtime_kind, - status=status, - exit_code=exit_code, - started_at=started_at, - finished_at=finished_at, - stdout_path=str(stdout_path), - stderr_path=str(stderr_path), - artifacts=artifacts, - error_summary=error_summary, - ) - - -def _run_with_process_group( - *, - invocation: RuntimeInvocation, - working_dir: Path, - stdout_path: Path, - stderr_path: Path, - env: dict[str, str], -) -> tuple[str, int | None, str | None]: - """Run the subprocess as a process-group leader and reap on timeout. - - Returns ``(status, exit_code, error_summary)``. - """ - with stdout_path.open("wb") as out_f, stderr_path.open("wb") as err_f: - proc = subprocess.Popen( - list(invocation.command), - cwd=working_dir, - env=env, - stdout=out_f, - stderr=err_f, - shell=False, - start_new_session=True, - ) - - try: - pgid: int | None = os.getpgid(proc.pid) if proc.pid else None - except OSError: - pgid = None - - def _kill_group() -> None: - if pgid is not None: - try: - os.killpg(pgid, signal.SIGKILL) - except (ProcessLookupError, OSError): - pass - - prev_sigterm = signal.getsignal(signal.SIGTERM) - - def _sigterm_handler(signum: int, _frame: object) -> NoReturn: - _kill_group() - signal.signal(signal.SIGTERM, prev_sigterm) - raise SystemExit(128 + signum) - - signal.signal(signal.SIGTERM, _sigterm_handler) - try: - try: - exit_code = proc.wait(timeout=invocation.timeout_seconds) - except subprocess.TimeoutExpired: - _kill_group() - try: - exit_code = proc.wait(timeout=5) - except subprocess.TimeoutExpired: - exit_code = None - error_summary = ( - f"process exceeded timeout of {invocation.timeout_seconds} seconds" - if invocation.timeout_seconds - else "process timed out" - ) - return "timed_out", exit_code, error_summary - finally: - signal.signal(signal.SIGTERM, prev_sigterm) - - status = "succeeded" if exit_code == 0 else "failed" - return status, exit_code, None - - -def _utc_now_iso() -> str: - return datetime.now(UTC).isoformat() diff --git a/tests/contracts/test_invocation_contract.py b/tests/contracts/test_invocation_contract.py index 1762fe0..f13c1ae 100644 --- a/tests/contracts/test_invocation_contract.py +++ b/tests/contracts/test_invocation_contract.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from executor_runtime.contracts.invocation import RuntimeInvocation +from core_runner.contracts.invocation import RuntimeInvocation def _base_invocation_kwargs() -> dict: diff --git a/tests/contracts/test_result_contract.py b/tests/contracts/test_result_contract.py index c66936e..4ef4e46 100644 --- a/tests/contracts/test_result_contract.py +++ b/tests/contracts/test_result_contract.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from datetime import UTC, datetime -from executor_runtime.contracts.result import RuntimeResult +from core_runner.contracts.result import RuntimeResult def test_result_defaults_artifacts_to_empty_list() -> None: diff --git a/tests/runners/test_async_http_runner.py b/tests/runners/test_async_http_runner.py index b52b03c..f35339a 100644 --- a/tests/runners/test_async_http_runner.py +++ b/tests/runners/test_async_http_runner.py @@ -5,8 +5,8 @@ import httpx -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.runners.async_http_runner import AsyncHttpRunner +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.runners.async_http_runner import AsyncHttpRunner KICKOFF_URL = "http://example.test/api/workflows/foo/run" POLL_TEMPLATE = "http://example.test/api/workflows/runs/{run_id}" diff --git a/tests/runners/test_http_runner.py b/tests/runners/test_http_runner.py index 6fbd2f0..ac29b41 100644 --- a/tests/runners/test_http_runner.py +++ b/tests/runners/test_http_runner.py @@ -5,8 +5,8 @@ import httpx import pytest -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.runners.http_runner import HttpRunner +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.runners.http_runner import HttpRunner def _invocation(**overrides) -> RuntimeInvocation: @@ -162,7 +162,7 @@ def test_network_error_returns_failed(self): class TestImportGuard: def test_construction_without_httpx_raises_when_no_client(self, monkeypatch): """If httpx is missing and no client is injected, the constructor errors.""" - import executor_runtime.runners.http_runner as mod + import core_runner.runners.http_runner as mod monkeypatch.setattr(mod, "httpx", None) with pytest.raises(ImportError, match="executor-runtime\\[http\\]"): HttpRunner() diff --git a/tests/runners/test_manual_runner.py b/tests/runners/test_manual_runner.py index 480a9d9..7272cfd 100644 --- a/tests/runners/test_manual_runner.py +++ b/tests/runners/test_manual_runner.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from datetime import UTC, datetime -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult -from executor_runtime.runners.manual_runner import ManualRunner +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult +from core_runner.runners.manual_runner import ManualRunner def _invocation(**overrides) -> RuntimeInvocation: diff --git a/tests/runners/test_subprocess_runner.py b/tests/runners/test_subprocess_runner.py index c3b7fe9..4d42f5c 100644 --- a/tests/runners/test_subprocess_runner.py +++ b/tests/runners/test_subprocess_runner.py @@ -2,8 +2,8 @@ import sys from pathlib import Path -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.runners.subprocess_runner import SubprocessRunner +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.runners.subprocess_runner import SubprocessRunner def _invocation(tmp_path: Path, command: list[str], **overrides) -> RuntimeInvocation: diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 23230c5..66b1ced 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -1,13 +1,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""Tests for dispatch-by-runtime_kind in ExecutorRuntime.""" +"""Tests for dispatch-by-runtime_kind in CoreRunner.""" from datetime import UTC, datetime import pytest -from executor_runtime import ExecutorRuntime -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult -from executor_runtime.runners.manual_runner import ManualRunner +from core_runner import CoreRunner +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult +from core_runner.runners.manual_runner import ManualRunner def _invocation(*, runtime_kind: str = "subprocess") -> RuntimeInvocation: @@ -42,7 +42,7 @@ def _result_for(invocation: RuntimeInvocation) -> RuntimeResult: def test_default_constructor_handles_subprocess_kind() -> None: """No-arg constructor still serves subprocess invocations.""" - runtime = ExecutorRuntime() + runtime = CoreRunner() # We don't actually run a real subprocess here — just confirm the # subprocess runner is the default for runtime_kind="subprocess". runner = runtime._runners["subprocess"] @@ -50,7 +50,7 @@ def test_default_constructor_handles_subprocess_kind() -> None: def test_unregistered_runtime_kind_returns_rejected() -> None: - runtime = ExecutorRuntime() + runtime = CoreRunner() inv = _invocation(runtime_kind="manual") result = runtime.run(inv) assert result.status == "rejected" @@ -65,7 +65,7 @@ def dispatcher(invocation: RuntimeInvocation) -> RuntimeResult: received.append(invocation) return _result_for(invocation) - runtime = ExecutorRuntime() + runtime = CoreRunner() runtime.register("manual", ManualRunner(dispatcher)) inv = _invocation(runtime_kind="manual") @@ -87,7 +87,7 @@ def man(invocation): man_called.append(invocation) return _result_for(invocation) - runtime = ExecutorRuntime( + runtime = CoreRunner( runners={ "subprocess": ManualRunner(sub), # use ManualRunner-as-fake for this test "manual": ManualRunner(man), @@ -102,14 +102,14 @@ def man(invocation): def test_legacy_runner_kwarg_still_works() -> None: - """Pre-dispatch ExecutorRuntime(runner=...) constructor.""" + """Pre-dispatch CoreRunner(runner=...) constructor.""" received: list[RuntimeInvocation] = [] def fake_subprocess(invocation): received.append(invocation) return _result_for(invocation) - runtime = ExecutorRuntime(runner=ManualRunner(fake_subprocess)) + runtime = CoreRunner(runner=ManualRunner(fake_subprocess)) inv = _invocation(runtime_kind="subprocess") result = runtime.run(inv) @@ -119,7 +119,7 @@ def fake_subprocess(invocation): @pytest.mark.parametrize("kind", ["http", "container", "unknown"]) def test_known_rxp_kinds_with_no_registered_runner_get_rejected(kind: str) -> None: - runtime = ExecutorRuntime() + runtime = CoreRunner() inv = _invocation(runtime_kind=kind) result = runtime.run(inv) assert result.status == "rejected" diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..af6b349 --- /dev/null +++ b/tests/test_process.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +import os +import signal +import sys +import textwrap + +import pytest + +from core_runner.process import SafeRunResult, safe_run + + +def test_zero_exit_success(): + result = safe_run([sys.executable, "-c", "import sys; sys.exit(0)"]) + assert result.returncode == 0 + assert not result.timed_out + + +def test_nonzero_exit_failure(): + result = safe_run([sys.executable, "-c", "import sys; sys.exit(42)"]) + assert result.returncode == 42 + assert not result.timed_out + + +def test_stdout_capture(): + result = safe_run([sys.executable, "-c", "print('hello')"]) + assert result.stdout.strip() == "hello" + assert result.returncode == 0 + + +def test_stderr_capture(): + result = safe_run([sys.executable, "-c", "import sys; sys.stderr.write('err\\n')"]) + assert result.stderr.strip() == "err" + + +def test_stdout_stderr_separate(): + result = safe_run( + [sys.executable, "-c", "import sys; print('out'); sys.stderr.write('err\\n')"] + ) + assert "out" in result.stdout + assert "err" in result.stderr + + +def test_env_overlay(tmp_path): + result = safe_run( + [sys.executable, "-c", "import os; print(os.environ['TEST_VAR'])"], + env={"TEST_VAR": "injected"}, + ) + assert result.stdout.strip() == "injected" + + +def test_cwd(tmp_path): + result = safe_run( + [sys.executable, "-c", "import os; print(os.getcwd())"], + cwd=str(tmp_path), + ) + assert result.stdout.strip() == str(tmp_path) + + +def test_timeout_kills_process(): + result = safe_run( + [sys.executable, "-c", "import time; time.sleep(60)"], + timeout_seconds=1, + ) + assert result.timed_out + assert result.returncode is not None + + +def test_process_group_kill_on_timeout(): + """Child that spawns a grandchild — both must die on timeout.""" + script = textwrap.dedent("""\ + import subprocess, sys, time + p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + time.sleep(60) + """) + result = safe_run([sys.executable, "-c", script], timeout_seconds=2) + assert result.timed_out + + +def test_capture_output_false(): + result = safe_run( + [sys.executable, "-c", "print('ignored')"], + capture_output=False, + ) + assert result.stdout == "" + assert result.stderr == "" + assert result.returncode == 0 + + +def test_safe_run_result_dataclass(): + r = SafeRunResult(returncode=0, stdout="a", stderr="b", timed_out=False) + assert r.returncode == 0 + assert r.stdout == "a" + assert r.stderr == "b" + assert not r.timed_out diff --git a/tests/test_runtime.py b/tests/test_runtime.py index ffb5155..2eb60d1 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -3,25 +3,25 @@ import sys from pathlib import Path -from executor_runtime.contracts.invocation import RuntimeInvocation -from executor_runtime.contracts.result import RuntimeResult -from executor_runtime.runners.subprocess_runner import SubprocessRunner -from executor_runtime.runtime import ExecutorRuntime +from core_runner.contracts.invocation import RuntimeInvocation +from core_runner.contracts.result import RuntimeResult +from core_runner.runners.subprocess_runner import SubprocessRunner +from core_runner.runtime import CoreRunner def test_default_facade_uses_subprocess_runner() -> None: - runtime = ExecutorRuntime() + runtime = CoreRunner() assert isinstance(runtime.runner, SubprocessRunner) def test_is_registered_reports_registered_kinds() -> None: - runtime = ExecutorRuntime() + runtime = CoreRunner() assert runtime.is_registered("subprocess") is True assert runtime.is_registered("manual") is False def test_facade_returns_runtime_result(tmp_path: Path) -> None: - runtime = ExecutorRuntime() + runtime = CoreRunner() invocation = RuntimeInvocation( invocation_id="inv-facade", runtime_name="local",