Packages

Run Codex jobs on an Oban queue, with validated args, result helpers, telemetry, and an optional long-lived agent lifecycle.

Current section

Files

Jump to
oban_codex SPEC.md
Raw

SPEC.md

# oban_codex contract
This document records the package boundary, provider translation, and backlog.
`oban_claude` is the reference architecture; parity is the default unless the
Codex CLI contract makes it misleading.
## Boundary
`oban_codex` owns:
- a string-keyed Oban args map to Codex query-options adapter;
- JSONL result readers;
- outcome-to-Oban classification;
- worker defaults, pinned args, and result/error hooks;
- offline testing fixtures;
- CLI, Igniter installer, telemetry;
- the opt-in experimental Agent lifecycle.
Oban owns storage, claiming, concurrency, retries, schedules, uniqueness,
recovery, and pruning. `codex_wrapper` owns CLI command construction and process
execution. The host application owns prompts, business policy, persistence,
external services, and isolated workspaces.
No GitHub-specific source/sink logic belongs here.
## Core contract
```elixir
ObanCodex.run(args, opts \\ [])
```
- `args` is a string-keyed map; `"prompt"` is required.
- known keys are forwarded; unknown raw-map keys are ignored.
- enum strings are converted through explicit allowlists.
- the default query path always forces Codex JSONL.
- a completed CLI invocation returns the underlying
`%CodexWrapper.Result{}`, including non-zero exits.
- pre-result errors are normalized as `%ObanCodex.Error{}`.
- the classifier must return `{oban_return, payload}`.
Curated args are the schema in `ObanCodex.Args`: model/profile, process config,
sandbox/approval controls, context directories, search, structured output,
images, config/feature controls, local-provider controls, and explicit session
resume.
`:env` is intentionally excluded because Oban stores args in plaintext.
## Result contract
Codex emits NDJSON, not a rich result struct. Public readers are:
- `events/1`
- `text/1`
- `structured/1`
- `outcome/1`
- `session_id/1`
- `usage/1`
- `cost_usd/1` (currently `nil` for normal Codex results)
Structured output is JSON text in the final `agent_message` event. A decoded
object or array is returned; malformed JSON and scalars return `nil`.
The thread identifier is `thread_id`, with `session_id` accepted as a defensive
event fallback.
## Resume compatibility shim
Codex CLI supports `codex exec resume --output-schema`, but
`codex_wrapper 0.4`'s `ExecResume` builder doesn't expose that flag.
`ObanCodex.Query.Resume` delegates every other option to `ExecResume.args/1`
and inserts `--output-schema` before the positional thread id and prompt.
Remove the shim after the minimum wrapper version exposes this option, with a
regression test proving identical arguments.
## Forks
Command layer:
- A fork is a run with `session_id: <source thread id>` plus
`fork_session: true`. `ObanCodex.Query` routes it to `codex exec fork`
through `CodexWrapper.ExecFork` (`codex_wrapper 0.5.0`).
- The new thread id arrives on the result (`thread.started`), so
`ObanCodex.session_id/1` returns the fork's id. The source thread is
unchanged.
- `fork_session: true` without a session id raises.
- `exec fork` rejects `profile`, `add_dir`, `color`, `oss` and
`local_provider`. Setting any of them with `fork_session: true` raises in
`Args.new/1`.
- A CLI without `exec fork` yields the typed, non-retryable reason
`{:unsupported, :exec_fork}`.
Agent:
- `Agent.fork_arc(agent_id, source_arc_id, target_arc_id, prompt, opts)` forks
the source arc's session into the target arc and runs the prompt on the
fork.
- A target arc that already has a session is replaced. The replaced session id
is reported in completion telemetry.
- The source arc handle is never rewritten. A failed fork leaves the target
unchanged.
- `source == target` returns `{:error, :same_arc}`.
- A source arc with no session handle returns
`{:error, {:enqueue_failed, {:fork_source_missing, source_arc_id}}}`.
- Job meta carries `continuation_decision: "fork"`, `fork_from_arc_id` and
`source_session_id`.
## Intentional divergence from oban_claude
- Codex policy is `sandbox` + `approval_policy`, not `permission_mode`.
- structured schema is a node-local file path, not inline JSON.
- no native worktree option is promised.
- no cost, max-turn, or max-budget fields are synthesized.
- Codex non-zero output has no stable typed error-kind contract; arbitrary
failures retry bounded by `max_attempts` unless a custom classifier knows
more.
- `session_id` is canonical; `resume` is only an API-parity alias.
## Agent contract
The Agent state machine remains provider-independent and matches
`oban_claude`:
```text
idle -> running -> idle
-> waiting_for_user
-> awaiting_permission
any state -> paused
```
Turns are ordinary Oban jobs. Results feed the machine through
`Agent.Job.handle_result/2`; retrying attempts stay logically in `running`;
completed failures return to idle or re-gate an incomplete approved action.
The captured Codex thread id is placed in the next turn's `"session_id"`.
`Agent.pause_after_turn/3` is the provider-independent, correlated
safe-boundary pause. It synchronously validates the live job's agent,
generation, and turn id before latching the first reason. Ordinary terminal
completion, failure, and watchdog expiry apply the latch. `ask_user` and
`request_permission` remain gated; one answer or approval continuation carries
the latch, and a new or incomplete gate keeps it. Rejection of a latched
permission request goes directly to `paused`. Resume clears the latch, while
emergency pause stays immediate and drops scopes plus the latch. Retry attempts
retain it for the same logical turn.
The latch records immutable source and latest-owner identities, including the
owner arc and application correlation id, so retries of either correlated call
are idempotent after the corresponding turn retires.
Transition telemetry adds `cause`, `pause_reason`, and `pause_action` on these
paths, plus `gate_outcome` and `action_id` for permission decisions. Existing
consumers continue to receive the original state and continuation metadata.
## Backlog
- Remove the resume output-schema shim after a wrapper release exposes it.
- Add wrapper-backed typed CLI failure events if Codex gains a stable machine
error envelope; then refine `Outcome` without parsing prose.
- Consider a pluggable cost calculator from token usage and model pricing. Do
not bake mutable pricing into the core package.
- Add a deployment recipe for external checkout/worktree managers.
- Keep the parity table checked whenever either Oban integration adds a public
feature.
## Release gate
- `mix format --check-formatted`
- `mix compile --warnings-as-errors`
- `mix test`
- `mix credo --strict`
- `mix dialyzer`
- `mix docs --warnings-as-errors`
- `mix hex.audit`
- package build against the released `codex_wrapper` dependency (no path dep)
- clean consumer compile against the unpacked Hex package