Agent Orchestration Service
  • Go 99.4%
  • Shell 0.4%
  • Just 0.2%
Find a file
2026-09-09 13:46:32 +08:00
.config Use native jj colocation for hybrid workspaces 2026-09-09 13:46:32 +08:00
docs fix: truncate oversized Lark cards on 230099 2026-08-28 13:37:41 +08:00
launchd fix: silence Just recipe echo 2026-08-18 00:19:42 +08:00
service fix: truncate oversized Lark cards on 230099 2026-08-28 13:37:41 +08:00
skills refactor: simplify 2026-07-20 18:05:49 +08:00
.env.example feat: if/while steps 2026-08-20 23:35:11 +08:00
.gitignore feat: remove .config from remote config 2026-08-20 00:39:18 +08:00
AGENTS.md feat: add loop control (break/continue) step to loop 2026-08-21 00:55:10 +08:00
dev.Justfile fix: silence Just recipe echo 2026-08-18 00:19:42 +08:00
install.sh feat: add installable orca commands 2026-08-17 02:51:51 +08:00
Justfile fix: silence Just recipe echo 2026-08-18 00:19:42 +08:00
orca.Justfile fix: silence Just recipe echo 2026-08-18 00:19:42 +08:00
orcad.Justfile fix: rotate daemon logs on start 2026-08-20 11:18:06 +08:00
README.md feat: if/while steps 2026-08-20 23:35:11 +08:00

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_init hooks run once, followed by the task's init steps.
  • Replies in a Lark thread associated with the Run execute local pre_resume hooks and then task resume steps, 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 end steps; 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_COUNT executions 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-send steps
  • Any executable referenced by a custom runner, such as codex, agent, or opencode
  • jq when 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.

  1. Open https://open.larkoffice.com/app, create or select the bot app, and copy its app ID and secret.
  2. Edit ~/.orca-config/source/.env and fill LARK_APP_ID and LARK_APP_SECRET.
  3. Run orcad install.
  4. Send .echo in a direct message with the bot.
  5. Put the returned Open ID and chat ID into DEFAULT_ALLOWED_LARK_USER and LARK_CHAT_ID in .env.
  6. Run orcad restart to 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:

  1. 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:message or im:message.reactions:write_only) plus im:message.reactions:read (or the broader im:message:readonly) so interrupted feedback can be reconciled.
  2. Add the bot to each conversation it should handle.
  3. Set LARK_APP_ID, LARK_APP_SECRET, and a concrete DEFAULT_ALLOWED_LARK_USER Open ID in .env.
  4. Add an enabled lark_bot trigger with an exact chat ID or "*", then put any explicit user policy at task level under access_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; resume additionally requires a task-unique workspace_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. A create_new firing initializes a fresh Run and a resume firing 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.