CodexWrapper.Runner behaviour (CodexWrapper v0.8.1)

Copy Markdown View Source

How one-shot codex subprocesses are executed.

Command.run/3 routes every synchronous command (Exec, ExecResume, Review, CodexWrapper.exec/2) through the configured runner. The default is CodexWrapper.Runner.Port, the /bin/sh-with-closed-stdin Port the library has always used.

When its port closes (a timeout, an early stream halt, the owning process dying, or the BEAM dying) the default runner signals the codex process: SIGINT, so a cooperating codex can stop its tool children, then SIGTERM and SIGKILL if it stays alive. It does not signal the process group, so processes codex does not stop itself can survive; see CodexWrapper.Runner.Port for the exact behavior and limits.

To kill the whole codex process group on a timeout or BEAM death (the CLI and every stdio MCP server it spawned), add forcola to your deps and select its runner:

# mix.exs
{:forcola, "~> 0.7.0"}

# config/config.exs
config :codex_wrapper, runner: CodexWrapper.Runner.Forcola

Runner.Forcola only compiles when forcola is present, so the dependency stays optional. See #48.

Contract

run/4 returns {:ok, {stdout, exit_code}} on completion (a non-zero exit is not an error -- callers decide what an exit code means), {:error, :timeout} when the timeout elapsed, and other {:error, reason} tuples for spawn/signal/io failures.

stream_lines/4 returns a lazy Enumerable of stdout lines for the NDJSON paths (Exec.stream/2, ExecResume.stream/2, Review.stream/2, and Session.stream/3 through the first two). Lines arrive without their trailing newline, stderr is never merged into them, and the stream ends when the process exits. A deadline emits a final {:error, {:timeout, ms}} or {:error, {:idle_timeout, ms}} item.

A non-zero exit ends the stream without raising, on either runner -- the same silent-halt the streaming paths have always had. Callers that need the exit code should use execute/2 rather than stream/2.

Summary

Types

Runner error reasons. :timeout is common to every runner.

Execution options, as produced by CodexWrapper.Config.cmd_opts/1

Callbacks

The timeout the runner will actually enforce for a caller's timeout.

Optional evidenced one-shot transport. Uses the same raw chunk observer as run_observed/5. Returns {legacy_outcome, evidence_map}; missing facts remain unknown. Evidence keys are documented in CodexWrapper.ExecutionObservation. The evidence map carries :stdout / :stderr bounded prefixes plus native :child_status, :child_signal, :termination, :reason, :output_truncated, :cleanup, :scope, and :guard_close when known. No raw child exit may be synthesized for a pre-spawn failure. Notifications must come from the calling process before return, preserving message order. timeout is a finite positive wall bound computed from the config. Forcola uses the snapshotted :forcola_default_timeout_ms (300,000 ms unless configured) when no explicit limit is set; other runners default to 300,000 ms. Idle and combined output defaults are 300,000 ms and 1 MiB. :idle_timeout_ms and :max_output_bytes are included in opts. Implementations must bound output and execution, preserve partial output on failure, and finish native cleanup before returning. No fallback is allowed.

Optional observed one-shot transport. Send raw stdout/stderr chunks as {reference, {:stdout | :stderr, bytes}} to the given target directly from the process calling this function, before returning its terminal outcome. Preserve all bytes and keep stderr separate regardless of legacy opts. CodexWrapper.ObservedExecution owns the calling helper and forwards typed identity from the execution caller. No runner fallback is allowed.

Run binary with args and stream its stdout as lines.

Optional capability preflight, checked before invoking run_evidenced/5.

Validate supported per-config requirements before any subprocess is opened.

Functions

effective_timeout/1 on runner, falling back to timeout unchanged when the runner does not implement the optional callback.

The configured runner module, CodexWrapper.Runner.Port by default.

Resolve the runner selected by a config.

Types

error()

@type error() ::
  :timeout
  | :idle_timeout
  | :max_output_bytes
  | :observer_overflow
  | {:signal, term()}
  | {:spawn, term()}
  | {:io, term()}

Runner error reasons. :timeout is common to every runner.

opts()

@type opts() :: keyword()

Execution options, as produced by CodexWrapper.Config.cmd_opts/1:

  • :cd -- working directory (string)
  • :env -- list of {name, value} string tuples
  • :stderr_to_stdout -- merge stderr into stdout

Callbacks

effective_timeout(timeout)

(optional)
@callback effective_timeout(timeout :: timeout() | nil) :: timeout() | nil

The timeout the runner will actually enforce for a caller's timeout.

Callers pass nil for "no timeout", but a runner may substitute a bound of its own (Runner.Forcola does, since forcola requires a finite one). Command.run/3 uses this to report the timeout that actually elapsed. Optional; defaults to the caller's timeout unchanged.

run(binary, args, opts, timeout)

@callback run(
  binary :: String.t(),
  args :: [String.t()],
  opts :: opts(),
  timeout :: timeout() | nil
) ::
  {:ok, {String.t(), non_neg_integer()}} | {:error, error()}

run_evidenced(t, list, opts, pos_integer, tuple)

(optional)
@callback run_evidenced(
  String.t(),
  [String.t()],
  opts(),
  pos_integer(),
  {pid(), reference()}
) ::
  {term(), map()}

Optional evidenced one-shot transport. Uses the same raw chunk observer as run_observed/5. Returns {legacy_outcome, evidence_map}; missing facts remain unknown. Evidence keys are documented in CodexWrapper.ExecutionObservation. The evidence map carries :stdout / :stderr bounded prefixes plus native :child_status, :child_signal, :termination, :reason, :output_truncated, :cleanup, :scope, and :guard_close when known. No raw child exit may be synthesized for a pre-spawn failure. Notifications must come from the calling process before return, preserving message order. timeout is a finite positive wall bound computed from the config. Forcola uses the snapshotted :forcola_default_timeout_ms (300,000 ms unless configured) when no explicit limit is set; other runners default to 300,000 ms. Idle and combined output defaults are 300,000 ms and 1 MiB. :idle_timeout_ms and :max_output_bytes are included in opts. Implementations must bound output and execution, preserve partial output on failure, and finish native cleanup before returning. No fallback is allowed.

run_observed(t, list, opts, arg4, tuple)

(optional)
@callback run_observed(
  String.t(),
  [String.t()],
  opts(),
  timeout() | nil,
  {pid(), reference()}
) ::
  {:ok, {binary(), non_neg_integer(), binary()}} | {:error, error()}

Optional observed one-shot transport. Send raw stdout/stderr chunks as {reference, {:stdout | :stderr, bytes}} to the given target directly from the process calling this function, before returning its terminal outcome. Preserve all bytes and keep stderr separate regardless of legacy opts. CodexWrapper.ObservedExecution owns the calling helper and forwards typed identity from the execution caller. No runner fallback is allowed.

stream_lines(binary, args, opts, timeout)

(optional)
@callback stream_lines(
  binary :: String.t(),
  args :: [String.t()],
  opts :: opts(),
  timeout :: timeout() | nil
) :: Enumerable.t()

Run binary with args and stream its stdout as lines.

Returns a lazy Enumerable of lines without their trailing newlines. Opening the process is deferred until the stream is consumed, and the process is signalled for termination when the stream halts (including an early Enum.take/2); Runner.Port and Runner.Forcola differ in how far that reaches.

timeout bounds the whole run. The independent opts[:idle_timeout_ms] bounds gaps between output frames; nil disables the idle bound. When timeout is nil, Runner.Port has no whole-run bound and Runner.Forcola uses its configurable default. Both built-in runners default to a 300,000 ms idle bound.

Optional. CodexWrapper.Runner.stream_lines/4 warns and falls back to Runner.Port for runners that do not implement it.

validate_evidence()

(optional)
@callback validate_evidence() :: :ok | {:error, term()}

Optional capability preflight, checked before invoking run_evidenced/5.

validate_options(keyword)

(optional)
@callback validate_options(keyword()) :: :ok | {:error, term()}

Validate supported per-config requirements before any subprocess is opened.

Functions

effective_timeout(runner, timeout)

@spec effective_timeout(module(), timeout() | nil) :: timeout() | nil

effective_timeout/1 on runner, falling back to timeout unchanged when the runner does not implement the optional callback.

impl()

@spec impl() :: module()

The configured runner module, CodexWrapper.Runner.Port by default.

Set with a module:

config :codex_wrapper, runner: CodexWrapper.Runner.Forcola

or with the :task / :forcola shorthand for the two built-in runners:

config :codex_wrapper, runner: :forcola

:forcola resolves to Runner.Port when the forcola dependency is absent, so the shorthand is safe to set unconditionally.

impl(map)

@spec impl(CodexWrapper.Config.t()) :: module()

Resolve the runner selected by a config.

resolve(module)

stream_lines(binary, args, opts, timeout)

@spec stream_lines(String.t(), [String.t()], opts(), timeout() | nil) ::
  Enumerable.t()

stream_lines/4 on the configured runner.

Falls back to CodexWrapper.Runner.Port with a warning when the configured runner cannot be loaded or does not implement the optional callback, so a runner written against the one-shot run/4 contract alone keeps working. A configured runner is loaded before checking the callback.