- Go 99.4%
- Shell 0.4%
- Just 0.2%
| .config | ||
| docs | ||
| launchd | ||
| service | ||
| skills | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| dev.Justfile | ||
| install.sh | ||
| Justfile | ||
| orca.Justfile | ||
| orcad.Justfile | ||
| README.md | ||
Orca
Orca is a local agent-orchestration daemon. It loads YAML task definitions, starts isolated Run workspaces from cron, one-off schedule, or Lark message triggers, and executes each task as an ordered series of Bash, file, messaging, or reusable runner steps.
The service is intended for trusted automation: task and runner configuration can execute arbitrary commands with the daemon user's permissions.
How it works
Each task defines three lifecycle phases:
trigger -> global pre_init -> init -> idle
| ^
matching Lark reply | | pre_resume -> resume
v |
idle
|
| idle TTL expires
v
expired
|
v
workspace + row removed
- A trigger creates a Run with its own directory under
$ORCA_CONFIG_DIR/runs. - Local
pre_inithooks run once, followed by the task'sinitsteps. - Replies in a Lark thread associated with the Run execute local
pre_resumehooks and then taskresumesteps, resetting the idle TTL. - When the idle TTL expires, reconciliation classifies the Run as expired, then
removes its workspace and metadata without depending on current task config.
Infrastructure cleanup failures retry with capped in-memory backoff; a daemon
restart forgets that state and retries immediately. The daemon expiry path
does not yet execute configured
endsteps; TD-074 tracks that contract. - Steps within one phase run in order and stop at the first exhausted failure.
Init and resume operations share a process-wide worker pool, with at most
ORCA_EXEC_WORKER_COUNTexecutions active at once. Operations for one Run execute one at a time in queue order; unrelated Runs may bypass a blocked same-Run item. Submission admission and preparation are synchronous and are not worker-pool work.
Run metadata, pending execution work, Lark thread mappings, Lark ingress
deduplication claims, and cron/schedule watermarks are stored in
$ORCA_CONFIG_DIR/db.sqlite3. Idle Runs, their
workspaces, and queued lifecycle work can therefore survive daemon restarts.
Before opening SQLite, the daemon takes a nonblocking exclusive lock on
$ORCA_CONFIG_DIR/daemon.lock; a second daemon targeting that state root exits
with the lock path and the owning PID when available. The persistent file is
only a rendezvous point and PID diagnostic—the kernel-held lock is the source
of ownership and is released even if the process crashes.
There is no exact crash recovery for a command that was already in flight; an
unacknowledged queue row is retried after restart and may repeat side effects.
Requirements
- Go 1.26.4 or the version declared in
service/go.mod just- Bash and Git
- macOS only for the included launchd integration
- Lark app credentials only when using Lark triggers or
lark-message-sendsteps - Any executable referenced by a custom runner, such as
codex,agent, oropencode jqwhen using the checked-in Codex, Cursor Agent, or OpenCode runners
Quick start
On macOS, install the managed source checkout, Go, Just, and the orca and
orcad commands with:
curl https://git.yongbeom.com/dernbu/orca/raw/branch/main/install.sh | bash
The installer clones Orca to ~/.orca-config/source by default, creates a
private .env, and installs command shims in /usr/local/bin. It does not
install or start the daemon.
- Open https://open.larkoffice.com/app, create or select the bot app, and copy its app ID and secret.
- Edit
~/.orca-config/source/.envand fillLARK_APP_IDandLARK_APP_SECRET. - Run
orcad install. - Send
.echoin a direct message with the bot. - Put the returned Open ID and chat ID into
DEFAULT_ALLOWED_LARK_USERandLARK_CHAT_IDin.env. - Run
orcad restartto reload the completed environment.
Set ORCA_CONFIG_DIR before running the installer to choose a different
absolute state root. Rerunning the installer is a safe repair: it preserves
.env and daemon state, updates a clean checkout to latest origin/main, and
recreates the shims without starting or restarting the daemon.
For repository development, copy .env.example to .env and use the
namespaced root recipes shown by just.
The Justfiles load .env; running go run directly does not.
Configuration
The checked-in configuration lives under .config:
.config/
├── global-hooks.yaml
├── runners/
│ └── *.yaml
└── tasks/
└── *.yaml
The checked-in codex, agent, and opencode runners use jq to extract a
harness session ID from machine-readable init output. Each corresponding task
step must set SESSION_ID_FILE_PATH to a path relative to its Run workspace.
An init invocation that exits zero atomically publishes the extracted ID (or
an empty file when no ID can be extracted), and later resume steps pass a
nonempty stored ID to the harness's exact-session option. A missing, unreadable,
or empty file falls back to that harness's --last or --continue behavior.
Init and resume steps use the same path to share a session; different paths
allow several harness sessions in one workspace.
A minimal task looks like this:
name: "Heartbeat"
id: heartbeat
enabled: true
ttl: 24h
on:
- cron: '*/5 * * * *'
mode: create_new
init:
steps:
- type: shell
run: |
set -euo pipefail
date -u
resume:
steps: []
end:
steps: []
See Configuration reference for the complete task,
trigger, phase, step, runner, global-hook, environment, and merge syntax.
Checked-in examples are available in .config/tasks and
.config/runners.
Optional remote configuration
Set ADDITIONAL_CONFIG_GIT_REPO to a comma-separated list of Git URLs to merge
additional configuration roots with the checked-in .config:
ADDITIONAL_CONFIG_GIT_REPO=git@example.com:team/orca-config.git,git@example.com:team-2/orca-config.git
Each repository must put tasks/ and runners/ at its root. The daemon clones
or refreshes each repository's default branch into its own stable checkout
under $ORCA_CONFIG_DIR/remote-config/<name>. Task IDs and runner
step_type values must remain unique across all roots. Global hooks are
local-only. Repositories refresh in env-var order. Each fetched revision is
validated together with the local configuration and repositories already
accepted earlier in that pass; a cross-repo duplicate rejects the later
checkout and rolls it back through first-parent history to the newest valid
ancestor, which remains checked out across daemon restarts. If no ancestor validates, Run admission is
blocked rather than falling back to local-only config. When the remote is
unreachable but a usable clone already exists on disk, refresh degrades instead
of blocking: the last on-disk checkout is re-validated and served until a
successful refresh, and daemon logs and debug report per-repository revisions,
served-offline flags, and served-ancestor flags; debug derives a served-offline
flag and the resolved revision of the last-refreshed repository from them.
Empty entries and duplicated URLs in the variable are rejected and block config
reads.
Validate one or more remote configuration checkouts together with the built-in configuration before publishing them:
orca validate ../orca-config ../team-2-orca-config
An invalid file or cross-root collision invalidates the combined configuration and prevents task execution.
Built-in config mirror
Before every init and resume Turn, the checked-in local hooks invoke a
compiled Go built-in to copy the complete repository .config tree to
./builtin-config-reference/ in the Run workspace. Remote-config authors can
inspect this mirror to discover local task IDs, runner step_type values, and
the complete definitions against which their changes will be merged.
The mirror is deliberately not sanitized. It includes committed built-ins and ignored machine-local files and may contain operationally sensitive configuration. It is intentionally available to every Run step and must not be treated as a secret-filtered or public interface. Removing write bits is only an accidental-edit guardrail: a process running as the daemon user can restore them, and this is not a security boundary. Workspace edits never propagate back to the live repository; the hook replaces the mirror on the next init or resume.
Refresh uses a staged complete copy and fails the Turn before task steps if the
source is missing, copying fails, or any symlink or special filesystem entry is
found. Supporting explicitly bounded symlinks can be reconsidered if a concrete
use case appears. The mirror is not refreshed before end, so a refresh
problem cannot block workspace cleanup. Live local config remains authoritative
for validation and can change after a mirror is copied.
Environment
just reads the ignored .env file. Supported service settings are:
| Variable | Purpose | Default |
|---|---|---|
ORCA_CONFIG_DIR |
State, remote config clone, database, and Run workspace root | ~/.orca-config |
ORCA_EXEC_WORKER_COUNT |
Maximum concurrent lifecycle workers; must be a positive integer | 12 |
ORCA_PROCESS_OUTPUT_CAPTURE_BYTE_LIMIT |
Bytes retained from the tail of each shell process's stdout and stderr stream; raw positive base-10 integer, applied daemon-wide after restart | 65536; invalid values warn and fall back to the default |
ORCA_DEFAULT_WHILE_LOOP_MAX_ITERATIONS |
Default maximum body executions for while steps that omit max_iterations; raw positive base-10 integer, applied daemon-wide after restart |
25; invalid values warn and fall back to the default |
ADDITIONAL_CONFIG_GIT_REPO |
Optional comma-separated Git URLs for additional config roots | empty |
LARK_CHAT_ID |
Fallback destination for task-produced Lark messages when the Run has no stored chat | empty |
LARK_APP_ID |
Lark app ID for the listener and messaging client | empty |
LARK_APP_SECRET |
Lark app secret for the listener and messaging client | empty |
DEFAULT_ALLOWED_LARK_USER |
Live Open ID allowed for inbound Lark operations when access_control.lark is absent |
empty; affected operations fail closed |
The daemon process environment is inherited by step scripts. It also injects Run, task, step, and Lark-message metadata; the full contract and precedence rules are documented in Environment available to steps.
Do not commit .env. Runner scripts and shell steps are trusted code and can
read the daemon's environment.
Lark setup and behavior
Lark integration is optional. Without both LARK_APP_ID and
LARK_APP_SECRET, the daemon logs a warning, skips the WebSocket listener, and
continues to run cron and schedule tasks.
To use it:
- Configure a Lark app for long-connection IM message events and grant the
permissions needed to receive messages and send/reply to them. Reaction
feedback additionally needs one write scope (
im:messageorim:message.reactions:write_only) plusim:message.reactions:read(or the broaderim:message:readonly) so interrupted feedback can be reconciled. - Add the bot to each conversation it should handle.
- Set
LARK_APP_ID,LARK_APP_SECRET, and a concreteDEFAULT_ALLOWED_LARK_USEROpen ID in.env. - Add an enabled
lark_bottrigger with an exact chat ID or"*", then put any explicit user policy at task level underaccess_control.lark.allowed_users. Omit the Lark policy to use the live default, list concrete Open IDs to replace it, or use["*"]to make the task explicitly public to valid Lark senders.
The dispatcher filters chat/regex matches through their task-level user policies.
One authorized task match starts a Run; several authorized matches produce the
existing conflict reply. A raw match with no authorization gets a generic
denial and runs nothing. A reply in an already-associated Lark thread is
re-authorized against the task's current explicit policy, or the live default
when no explicit policy exists. This applies even if a non-Lark trigger started
the Run. A denial never falls through to a new command. The built-in
lark-message-send accepts an optional mode.
thread_reply is the backward-compatible default: it replies in the Run's
associated thread or, if none exists, starts the first message in
LARK_CHAT_ID. new_message
always starts a fresh top-level message in the authoritative chat and moves the
Run binding to the returned message metadata, so replies to the superseded
thread no longer resume the Run.
Accepted daemon lifecycle work, including Lark work, waits in an unbounded
SQLite-backed queue. Admission returns only after its row commits. The
queue/store serializes work FIFO within each Run and allows unrelated Runs to
use free workers. Before returning work to a worker, it atomically changes the
row from queued to in_flight only if that Run has no other in-flight row.
Only then does the worker report Started and invoke the executor. Startup
first restores every persisted running Run to idle, refreshing its expiry
from the startup time and persisted TTL, then resets in_flight queue rows to
queued before replay. Either recovery failure aborts service startup and is
retried by the next daemon start.
Succeeded, failed, and rejected work ACKs only the delivered row. It does not yet cancel already-queued work for the same Run. Interrupted work is retained for restart recovery. An ACK failure is logged and also leaves the row for restart recovery.
Before forwarding a validated inbound Lark message, the adaptor atomically
claims its message ID in SQLite. Another delivery of the same pending ID does
not reach the dispatcher and returns a retryable error; after the owner
succeeds, a retry observes success and is acknowledged. Claim blocks on any
existing success row regardless of age; hourly reconciliation removes expired
successes, which is the only way an ID is re-enabled. A downstream
admission error releases the claim and is returned so Lark can retry. Startup
removes pending claims inherited from a prior daemon before starting the
listener. A failed release is retried when the next pending redelivery arrives.
A crash between durable downstream acceptance and the success transition
remains deferred under TD-082 in TODOs.md.
The durable payload includes the task/runner/hook snapshot and invocation environment (including Lark chat, message, thread, and sender values). Daemon configuration environment and lifecycle callbacks are not persisted. Recovered work snapshots the former from the new daemon and has no callback. Lifecycle callbacks remain the internal completion path for schedule watermarks and cleanup; cron watermarks advance synchronously after durable queue admission. Callbacks do not send Lark feedback. Debug submission returns at durable queue admission and does not install a completion callback.
The execution service submits user-facing feedback independently for admitted,
started, and terminal execution states. The dispatcher resolves the target from
the turn's persisted LARK_MESSAGE_ID, so recovered work can still update its
original message. Turns without a message ID are submitted and become no-ops in
the dispatcher. Pre-admission failures use a raw Lark message ID; thread IDs are
not accepted as feedback targets.
For handled Lark messages, feedback adds OneSecond while work is queued,
replaces it with Typing when a worker starts execution, and removes the
managed reaction on success. It leaves a CrossMark on failures and
pre-execution rejections. Existing authorization, multiple-match, and expired
Run replies flow through the same dispatcher. V1 delivery is process-local,
fire-and-forget, unbounded, unordered, and not retried or drained at shutdown;
delivery failures are logged and never change the task result. Reaction
ownership is stored in SQLite before Lark is mutated; after restart, the
listener waits for stale owned reactions to be reconciled before accepting
messages.
Trigger delivery notes
- Cron: every cron entry requires
mode: create_new | resume;resumeadditionally requires a task-uniqueworkspace_key. Each trigger contributes at most one missed firing per scan and owns a watermark keyed by a global identity containing its task ID, index, and complete-trigger hash. Editing or reordering a trigger makes it new. Acreate_newfiring initializes a fresh Run and aresumefiring continues its workspace's Run (starting a first-use init when no binding exists). A per-trigger process mutex covers eligibility, durable admission, and watermark persistence; the watermark advances at admission, so later execution failure does not retry that occurrence. Queue delivery serializes work for the same continuing Run. This is a hard cutover: migration 015 drops every legacy task-level cron watermark, so every configured trigger fires once as new after rollout. The old reader rejects the new fields and the new validator rejects old cron entries, so stop scheduling while publishing the new remote config and binary/local config, validate them together with the new binary, then restart. - Schedule: due timestamps are evaluated with a per-task SQLite watermark. A timestamp missed during downtime is run after restart. The watermark is saved only after successful task initialization, favoring duplicate delivery over loss.
- Watermark cleanup: after each successful daemon configuration refresh (at startup and once per minute), cron rows whose exact trigger identity is absent and schedule rows whose task ID is absent are removed from the complete validated configuration. Current disabled triggers/tasks retain their rows. Each table cleans atomically and independently; failures are logged without blocking dispatch and are retried after the next refresh. Cleanup is eventually consistent with dispatch already in flight.
- Lark: message regular expressions use Go syntax. Zero raw matches do nothing; unauthorized matches are denied before Run admission; multiple authorized task IDs produce an error reply and run nothing. These application outcomes are acknowledged and mark the inbound message ID successful. A durable queue-admission failure releases the pending ID and is returned to Lark so it may redeliver. A pending duplicate is also returned for retry without re-running selection or admission; a successful duplicate is acknowledged.
Use idempotent task actions whenever a trigger can be delivered more than once.
Commands
| Command | Description |
|---|---|
orca help |
Show runner-service commands. |
orca ping |
Ping the running daemon over its private control socket. |
orca debug <task_id> |
Refresh config and queue one task initialization, even when disabled. |
orca validate [config_dir ...] |
Validate local config plus optional additional roots. |
orcad help |
Show daemon-management commands. |
orcad run |
Run the daemon in the foreground. |
orcad install |
Render, lint, install, and load the macOS LaunchAgent. |
orcad start |
Start a stopped daemon or restart a running daemon. |
orcad restart |
Alias for orcad start. |
orcad stop |
Stop the daemon while retaining its installed plist. |
orcad status |
Print LaunchAgent state. |
orcad update |
Fast-forward the managed checkout to latest origin/main; restart only if loaded. |
orcad uninstall |
Unload the daemon and remove its plist, retaining source, config, state, and shims. |
just |
List the namespaced dev, orca, and orcad repository recipes. |
orca validate runs the architecture lint as part of required validation.
Fix reported imports instead of broadening the target allowlist solely to make
the check pass.
Successful shell-step output is not printed. Orca continuously drains stdout
and stderr while retaining only the most recent
ORCA_PROCESS_OUTPUT_CAPTURE_BYTE_LIMIT bytes independently for each stream.
On failure, the service logs those retained tails in the existing stdout and
stderr fields without truncation metadata. Task, runner, and step environment
overlays cannot change this daemon-wide limit.
Managing the installation
On macOS:
orcad install
orcad status
The stable com.yongbeom.orca LaunchAgent runs /usr/local/bin/orcad run,
restarts automatically, and writes logs under $ORCA_CONFIG_DIR/logs. Use
orcad stop to unload it while preserving its plist, and orcad start to load
it again. Both orcad start and orcad restart rotate stdout.log to
stdout.bak and stderr.log to stderr.bak before starting the daemon. After
editing .env, restart it with orcad restart.
orcad update rejects tracked local changes, fetches origin/main, and
fast-forwards the managed checkout. It restarts a loaded daemon after the
update and leaves an unloaded daemon stopped. It does not roll back source if
that post-update restart fails.
Remove only the daemon registration with:
orcad uninstall
For a complete default-root removal, first run orcad uninstall, then run
rm -rf "$HOME/.orca-config" and remove /usr/local/bin/orca and
/usr/local/bin/orcad (using sudo if required). Resolve and substitute your
actual config directory if you installed with ORCA_CONFIG_DIR; do not copy a
placeholder path into a recursive removal command. Homebrew, Go, and Just are
shared dependencies and are intentionally retained.
State and generated files
With the default ORCA_CONFIG_DIR, runtime state is:
~/.orca-config/
├── source/ # Managed origin/main source checkout and private .env
├── logs/ # LaunchAgent stdout.log and stderr.log
├── db.sqlite3 # Runs, execution queue, Lark metadata, trigger watermarks
├── daemon.lock # persistent kernel-lock rendezvous and diagnostic owner PID
├── daemon.sock # private control endpoint while the daemon is running
├── remote-config/ # one checkout per additional config repo
└── runs/ # active Run workspaces
Do not edit any remote-config/<name>/ checkout directly. It is a disposable
clone: refresh follows the origin's current default branch and removes all
tracked, untracked, and ignored checkout drift. Invalid repositories are
removed and cloned again at their own derived path. If the update steps fail
but a usable clone already exists on disk, refresh degrades and serves that
on-disk fallback until a successful refresh; only a recovery with nothing
usable on disk blocks config reads. Validation may leave a checkout at a
validating first-parent ancestor. Older installations may
retain an unused repos/ cache from the former worktree implementation; the
current service does not read it. The state database and Run workspaces can
contain task inputs, Lark message contents, command output, secrets, and the unsanitized
builtin-config-reference/
mirror. At startup, the daemon repairs the state root and runs root to mode
0700 and the SQLite database to 0600; a symbolic link at the final runs
path is rejected. Periodic reconciliation removes old managed workspace
directories only when neither a Run row nor an active init request owns them;
a one-hour grace period protects the creation interval.
Development
The main code is under service/:
service/cmd/daemon daemon and one-minute dispatch loop
service/cmd/utils validation and daemon-control clients, including debug submission
service/internal/config YAML models, readers, validation, config refresh
service/internal/dispatch cron, schedule, debug, and Lark admission
service/internal/exec Run lifecycle, environments, and step execution
service/internal/adaptor filesystem, Git, Lark, and SQLite integrations
Before submitting a change:
just orca validate
just dev test
Contributor-specific architecture and debugging notes live in
AGENTS.md. Ownership boundaries are documented in
docs/ownership.md, and active work is tracked in
TODOs.md.
Troubleshooting
Task not found for a remote task
Run through orca or the namespaced Just recipe. Direct go run does not load .env, so
ADDITIONAL_CONFIG_GIT_REPO is empty and the remote config is not fetched.
Lark listener is disabled
Set both LARK_APP_ID and LARK_APP_SECRET. Cron and schedule dispatch remain
available without them.
Configuration validates locally but not with the remote root
Run orca validate /path/to/remote-checkout. Look for duplicate task IDs,
duplicate runner step_type values, or a custom runner environment requirement
that no step or supported service setting provides.
A debug submission appears silent
That is expected after successful queue admission: orca debug returns
without waiting for execution or printing a task result. Inspect the running
daemon's logs and the task's configured side effects. Redirecting the debug
command captures only client-side socket, refresh, validation, lookup, or
admission errors.
A schedule or cron action ran twice
Delivery is at least once in several failure and restart paths. Make the action idempotent; see Trigger delivery notes.