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.ForcolaRunner.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.
stream_lines/4 on the configured runner.
Types
@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.
@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
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.
@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.
@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.
@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.
@callback validate_evidence() :: :ok | {:error, term()}
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.
@spec impl() :: module()
The configured runner module, CodexWrapper.Runner.Port by default.
Set with a module:
config :codex_wrapper, runner: CodexWrapper.Runner.Forcolaor 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.
@spec impl(CodexWrapper.Config.t()) :: module()
Resolve the runner selected by a config.
@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.