CodexWrapper.Runner behaviour (CodexWrapper v0.4.0)

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.

For leak-free execution -- where a timeout or BEAM death kills the whole codex process group (the CLI and every stdio MCP server it spawned) rather than abandoning it -- add forcola to your deps and select its runner:

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

# 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 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.

Run binary with args and stream its stdout as lines.

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.

Types

error()

@type error() :: :timeout | {: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()}

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 terminated when the stream halts (including an early Enum.take/2).

timeout is enforced as each runner can: Runner.Port applies it as an idle bound between output frames (what the streaming paths have always done), Runner.Forcola as forcola's whole-run bound with a process-group kill. nil means each runner's own default.

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

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.

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 when the configured runner does not implement the optional callback, so a runner written against the one-shot run/4 contract alone keeps working.