Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 10 additions & 6 deletions .orca/reviewers/orca.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ performance, tests — belongs to the shipped reviewers running beside you.
- **Forge neutrality**: interfaces and persisted documents name git concepts,
never a forge; a forge-specific type stays in the package that talks to it.
- **New mutable state is a design question**: a new `var` field, mutable
collection or `AtomicReference` needs the task description or a comment at
collection, `AtomicReference` or `orca.concurrency.Ref` needs the task description or a comment at
the declaration to name the alternatives (actor, method-local state, an
`AtomicReference` over an immutable value) and why each was rejected —
report the missing rationale, not the state itself. Actor-held state and an
`AtomicReference` over an immutable value updated by a pure function are the
sanctioned forms.
`AtomicReference` (`orca.concurrency.Ref` in safe modules) over an immutable value
updated by a pure function are the sanctioned forms; never
`caps.SharedCapability` + `caps.Stateful`. Safe mode still compiles
`@volatile` and `synchronized`, so flag them as before.
- **No back-compat machinery**, and no default values on domain or persisted
fields.
- **Failure types**: a recoverable `Either[E, T]` has `E <: OrcaFlowException`;
Expand All @@ -42,10 +44,12 @@ performance, tests — belongs to the shipped reviewers running beside you.
WorkspaceWrite)` to make code compile; `WorkspaceWrite` never crosses a fork;
a new gated write calls `WorkspaceWrite.check` first.
- **Writes under `.orca/`**: a whole-file write replaces an `OrcaDir.OrcaFile`;
any other write goes inside an `OrcaDir.ensure*` directory, with `os.write`
over `os.write.over`. A new file gets a row in AGENTS.md's "What a run writes
any other write goes inside an `OrcaDir.ensure*` directory, with
`fs.writeNew` over `fs.writeOver` (plain modules: `os.write` over
`os.write.over`). A new file gets a row in AGENTS.md's "What a run writes
to disk".
- **Subprocesses capture stderr** — `QuietProc.call` or a `CliRunner`; a
- **Subprocesses capture stderr** — `Processes`, a `CliRunner`, or
`QuietProc` in plain modules (runner/shell); a
`spawnPiped` caller drains `stderrLines` as well as `stdoutLines`. Raw
`os.proc` with `os.Inherit` only in the `shell/` terminal handoffs and
`TtyProbe`.
Expand Down
11 changes: 2 additions & 9 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1,9 +1,2 @@
version = 3.8.3
runner.dialect = scala3

# scalafmt 3.8.3's scala3 dialect parser cannot tokenize capture-checking
# syntax (the `C^` capture-set type parameter and `->{C}` capture arrows in
# CheckedPar.scala), failing before any `// format: off` region is consulted.
# Exclude that one file until scalafmt gains capture-checking support; it is
# hand-formatted to match the surrounding style.
project.excludeFilters = ["tools/src/main/scala/orca/CheckedPar.scala"]
version = 3.11.5
runner.dialect = scala3future
133 changes: 94 additions & 39 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,22 @@ orca live in [CONTRIBUTING.md](CONTRIBUTING.md); end-user documentation at
[orca.virtuslab.com](https://orca.virtuslab.com) (source in `docs/`).

Orca is implemented in Scala 3 on top of [Ox](https://ox.softwaremill.com/)
for structured concurrency, [tapir](https://tapir.softwaremill.com/) for
JSON Schema derivation, and
for structured concurrency, [tapir](https://tapir.softwaremill.com/)'s
schema model to describe and render JSON Schema (derived by
`orca.json.SchemaDerivation`, in `core`), and
[jsoniter-scala](https://github.com/plokhotnyuk/jsoniter-scala) for codec
generation.

## Project layout

```
orca/
├── build.sbt / project/
├── tools/ # tool traits + os-backed impls (git/gh/fs), internal LLM SPI + session durability, InStage, events, subprocess
├── flow/ # stage/display/fail + FlowContext/FlowControl; orca.{plan,review,pr,progress}
├── claude/ codex/ gemini/ opencode/ pi/ # one module per coding-agent backend
├── runner/ # flow() entry, DefaultFlowContext/DefaultFlowControl, FlowLifecycle, terminal UI
└── shell/ # `orca-shell`, the `orca` CLI's interactive front-end (ADR 0021)
```

Dependency graph:

```
tools (standalone)
├── flow → tools
├── claude / codex / gemini /
│ opencode / pi → tools
├── runner → tools + flow + all five backends
└── shell → runner (published `orca`)
```
The module tree and dependency graph are in
[docs/development.md](docs/development.md#layout).

The runner module owns the `flow` entry point (`package orca`) and wires
defaults via `DefaultFlowContext` and `DefaultFlowControl` (`package orca.runner`); the stage
setup/teardown/recovery state machine is `orca.runner.FlowLifecycle`. The
terminal UI lives in `orca.runner.terminal`, behind an `Interaction`, so
setup/teardown/recovery state machine is `orca.lifecycle.FlowLifecycle`, in the
`lifecycle` module; what runner and shell use from it is `private[orca]`.
The terminal UI lives in `orca.runner.terminal`, behind an `Interaction`, so
swapping it for a Slack or HTTP equivalent is one substitution at the call
site rather than rewiring modules.

Expand All @@ -48,14 +32,77 @@ backend-agnostic role accessors (ADR 0020) — `stage`/`display`/`fail`,
Implementations live in
focused subpackages: `orca.tools` (os-backed git/gh/fs impls + their traits),
`orca.gitref` (validated branch names and commit hashes, and `Head`),
`orca.json` (`JsonData`, `RawJson`, whole-file `JsonFile` documents),
`orca.json` (`JsonData` and `JsonCodec` in core; `RawJson`, whole-file
`JsonFile` documents),
`orca.agents` + `orca.backend` (LLM SPI, `SessionSupport`, per-backend
decoders; the SPI is `private[orca]` or `private[backend]`, so a new harness
is added in orca itself), `orca.subprocess` (subprocess shim), `orca.events`
(event bus), one `orca.backend.<backend>` per coding agent, and `orca.runner` /
`orca.runner.terminal` (wiring + terminal UI). The flow module adds
(event bus), one `orca.backend.<backend>` per coding agent, `orca.lifecycle`
(run setup, recovery and teardown), and `orca.runner` / `orca.runner.terminal`
(wiring + terminal UI). The flow module adds
`orca.{plan,review,pr,progress}`.

### Safe mode

Modules are moving, bottom-up, onto Scala's safe mode, where code may only
reference safe-mode or `@assumeSafe` definitions. The main sources of `tools`,
`flow`, `lifecycle` and the five backends compile with
`-language:experimental.safe` (their tests do not); `runner` and `shell` are
plain (wiring, terminal, logging, file locks). `core` is the trusted base, and the only module where
`@assumeSafe` appears (`AssumeSafeScopeTest`):

- Capabilities (`orca.effect`): `FileSystem`, `Processes`, `Network`. Each
is a `caps.SharedCapability` whose members perform the effect; its
`ambient` mint is `private[orca]` and not `@assumeSafe`, so only wiring and
tests create one.
- Facades over libraries safe code can't reference: `orca.safe.{prelude,
Logger}`, `orca.concurrency` (with `Ref`), `orca.mcp`, `orca.json.{JsonData,
JsonCodec, JsonSchemas}`.
Safe code uses `derives JsonCodec` where plain code uses `derives
ConfiguredJsonValueCodec`.
- Effect implementations (`QuietProc`, `OsPipedSubProcess`, `LocalServer`,
`JdkHttpClient`, `CookieScan`) are not `@assumeSafe`; safe code reaches them
through a capability only (`SafeModeExposureTest`). A handle a capability
returns (`PipedCliProcess`, `HttpClient`) is untracked: code holding one
performs its effects without naming the capability.

In safe modules, effectful classes (`OsGitTool`, `Worktrees`, `OrcaDir`,
`JsonFile`, `OsFsTool`, the backends) take capabilities as constructor
parameters; wiring (`flowEntry`, the `*Agents` objects, the shell) builds them
with `X.ambient`. `lifecycle` takes the terminal (`tty`, `ask`), the PATH
probe and the worktree file lock (`WorktreeLock`) as values from `flowEntry`. Safe code can't turn a class holding a capability into a
plain trait such as `AgentBackend` or `CliRunner`, so each safe backend's
`*Agents` object, and its `*AgentsTest`, lives in `runner`, in the backend's
package (`orca.backend.<backend>`). A session probe that reads the disk is
built there too and passed to the backend (`CodexBackend.rolloutProbe`), and
so is a factory for the `GitTool` the backend reads through: a backend starts
processes only through its `CliRunner`. A probe or factory that needs a
directory takes it as an argument, and the backend passes its own `workDir`.

Safe mode does not check member calls on values, so it catches ambient
effects reached by accident; it is not a sandbox. `JsonData` derives schemas
with `SchemaDerivation`, because tapir's Magnolia `Schema.derived` fails
capture checking. It ignores tapir's schema annotations, and the
`*SchemaParityTest` suites pin its output to tapir's; `SchemaParityCoverageTest`
fails when a derivation site has no parity entry.

The prelude's `pipe` and `tap` are inline, and Scala 3.10.0 types an inline
argument's proxy without its captures: on a value that captures a capability
they can fail capture checking ("Found `X^{fs}`, Required `X`"). Use a named
`val` there instead.

In 3.10.0, `List.maxByOption` takes a pure function, so a key that uses a
capability fails ("capability fs cannot flow into capture set {}"): `map` to
`(value, key)` pairs first, then `maxByOption(_._2)`.

Scala 3.10.0's `SafeRefs.checker` shares a mutable flag across compiler
instances in one JVM, so concurrent safe-mode compiles report false "Cannot
refer to ..." errors; `build.sbt` compiles one module at a time. The flag can
stay stuck in a long-lived sbt server: if those errors appear, `sbt shutdown`.

Migrating a module: sbt reports only 100 errors; `set ThisBuild / maxErrors
:= 2000` shows them all.

## The stage-bound runtime

The flow runtime is specified in [ADR 0018](adr/0018-stage-bound-flow-runtime.md) —
Expand All @@ -64,7 +111,8 @@ most easily broken:

- **Capability gating.** `FlowContext` (reads + emit; thread-safe) is not a
capability, so forks receive it freely. Three compile-time capabilities gate
side effects: `FlowControl` (authority to start a stage; thread-affine;
flow-level side effects (starting stages, LLM runs, workspace writes),
separate from core's `FileSystem`/`Processes`/`Network`: `FlowControl` (authority to start a stage; thread-affine;
unrelated to `FlowContext` — `flow` provides both, and a stage-starting
helper takes `(using FlowContext, FlowControl)`), and a SPLIT pair of
stage-bound capability tokens (both in `tools`, `package orca`) — `InStage`,
Expand Down Expand Up @@ -385,11 +433,16 @@ with a test pinning the observed wire shape.
### Scala style

- Braceless syntax; explicit return types on every public member.
- No class-level `var`s outside an Ox actor's state. Mutable state whose
updates also write to the terminal or files lives in an actor; other
mutable state stays in method bodies, or in an `AtomicReference` over an
immutable value updated by a pure function (`CostTracker`,
`DeniedToolTracker`).
- Remove mutable state first: use a fork's return value, a fold, or state a
channel or scope already holds. No class-level `var`s outside an Ox actor's
state. Mutable state whose updates also write to the terminal or files lives
in an actor; other mutable state stays in method bodies, or, when shared
across threads, in an `AtomicReference` over an immutable value updated by a
pure function (`CostTracker`, `DeniedToolTracker`) — `orca.concurrency.Ref` in safe
modules. Safe mode rejects `var` fields, so there an actor's state, and
state confined to one thread (`StageFrames`), is a `Ref` too. Never `caps.SharedCapability` + `caps.Stateful` for it: that tracks
nothing. `@volatile` and `synchronized` still compile in safe mode, so review
is the gate.
- Opaque-type aliases for domain string labels (e.g. `Title`, `SessionId`).
- Recoverable failures return `Either[E, T]` where `E <: OrcaFlowException`;
system failures throw. Use Ox's `.orThrow` at the call site when the
Expand Down Expand Up @@ -558,17 +611,18 @@ number, change it only additively (ADR 0025).
[ADR 0010](adr/0010-prompts-and-helpers-convention.md): sibling
`XxxPrompts` object + `instructions: String = …` parameter.
- Subprocesses launched from a tool **must** capture stderr — go through
[`subprocess.QuietProc.call`](tools/src/main/scala/orca/subprocess/QuietProc.scala)
or a `CliRunner`. os-lib defaults `os.proc(...).call(...)`'s `stderr` to
[`effect.Processes`](core/src/main/scala/orca/effect/Processes.scala),
a `CliRunner`, or `QuietProc` in plain modules (runner/shell). os-lib defaults `os.proc(...).call(...)`'s `stderr` to
`Inherit`, which lets subprocess output bypass the renderer's StatusBar
and tear the spinner row. `CliRunner.spawnPiped` always pipes stderr, so
its caller must drain `stderrLines` as well as `stdoutLines`. Raw `os.proc`
with `os.Inherit` is only for the `shell/` terminal handoffs (editor, flow
run, agent session) and `TtyProbe`, which inherits the fd it probes.
- Every `spawnPiped` child carries a unique `ORCA_TURN_COOKIE`
(`orca.subprocess.EnvCookie`). `SubprocessSpawn.open` registers `EnvCookieSweep`
with the turn scope, so at turn end it scans `/proc/*/environ` for the
agent's cookie and REPORTS what is still running — the backstop for work an
with the turn scope, so at turn end it asks the process's `survivors()` (a
`/proc/*/environ` scan) for what carries the agent's cookie and REPORTS what is still
running — the backstop for work an
agent detached from orca's process tree, which no parent-link teardown can
reach. opencode's turns spawn nothing (they run on the per-run server), so
they are not swept; the server's cookie is swept when the server stops.
Expand All @@ -582,8 +636,9 @@ number, change it only additively (ADR 0025).
`OrcaDir.OrcaFile` (only `OrcaDir` creates one) and its `replace`, whose
rename replaces a leaf symlink instead of following it. Other writes (appends,
lock files) go inside a directory an `OrcaDir.ensure*` accessor returned, with
`os.write` (`CREATE_NEW`, refuses a leaf symlink) over `os.write.over`; a lock
file opens with `NOFOLLOW_LINKS`. The
`fs.writeNew` (`CREATE_NEW`, refuses a leaf symlink) over `fs.writeOver`
(plain modules: `os.write` over `os.write.over`); a lock file opens with
`NOFOLLOW_LINKS`. The
check runs at the earliest `.orca` touch (`FlowLock` → `ensureCache`), ahead
of any mutation.

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ invoke the `direct-style-scala` skill (via the Skill tool). This applies to
subagents dispatched to implement or review a task: load the skill first,
then start the work.

When a change would add mutable state — a `var`, a mutable collection or an
`AtomicReference` field — research the alternatives in a separate agent and
When a change would add mutable state — a `var`, a mutable collection, an
`AtomicReference` or an `orca.concurrency.Ref` field — research the alternatives in a separate agent and
have a second agent critique the recommendation before implementing either.
`.orca/reviewers/orca.md` says what the result must record.
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ rewrite (the flows resolve from Maven Central), so it skips itself.
sbt publishLocal
```

Installs `org.virtuslab::orca:0.0.17` plus its transitive modules
(`orca-tools`, `orca-flow`, and the five backends
`orca-{claude,codex,gemini,opencode,pi}`) into `~/.ivy2/local` so a flow
script with `//> using repository ivy2Local` can resolve them. For an
Installs every module into `~/.ivy2/local`: `org.virtuslab::orca` (the runner),
the modules it depends on (`orca-core`, `orca-tools`, `orca-flow`,
`orca-lifecycle`, and the five backends `orca-{claude,codex,gemini,opencode,pi}`),
and `orca-shell`. A flow script with `//> using repository ivy2Local` can then
resolve them. For an
iteration loop, `sbt "~publishLocal"` rebuilds and republishes on every
save.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Flows are ordinary Scala scripts. For example, the below one plans, implements
each task with a review, and opens a PR:

```scala
//> using scala 3.9.0
//> using scala 3.10.0
//> using dep "org.virtuslab::orca:0.2.1"
//> using jvm 21

Expand Down
5 changes: 3 additions & 2 deletions adr/0025-run-event-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ stay as log files; the event log points to them.
- **One writer per attempt, asynchronous.** The attempt keeps an in-memory
projection of the log, loaded from the file at start (empty if absent). The
projection is an immutable value held by an actor; the run reads it through
that actor. A second actor appends events to the file in the background.
that actor. The actor queues each event on a channel, and one writer fork
drains it, appending to the file in the background.
Writes lost in a crash are acceptable: this is a cache.
- **Run boundary in the log.** A run of the same prompt reuses the key and the
file. Success appends `RunSucceeded`; session lookup ignores everything before
Expand Down Expand Up @@ -85,7 +86,7 @@ stay as log files; the event log points to them.
- The session store's writes are no longer a read-modify-write of one file, so
`upsert` drops `WorkspaceWrite`; the actor serialises updates.
- Queued appends are lost when the attempt's scope ends, so finishing an
attempt waits for the appender's queue to empty.
attempt closes the queue and waits for the writer to drain it.
- No compatibility with existing cache files is owed; old manifests stop being
listed.
- Supersedes ADR 0021 §8's file layout (manifest and cost log) and the location
Expand Down
Loading
Loading