Skip to content

The execution model

Execution in Chorus is first-class and native. A unit of work is an ExecutionTask; each claim opens a RunAttempt; an attempt produces Delivery records (a PR, patch, or report) and uses Checkout records (a sandbox). All four are distinct rows with their own lifecycle vocabularies, defined in src/db/adapter.ts:227-250.

ExecutionTask — the unit of work (RFC-291)
└─ RunAttempt — one per claim; attempt_number; claim_lock; primary_delivery?
├─ Delivery — a PR / patch / report; is_primary; required_for_completion
└─ Checkout — a sandbox: worktree / clone / container / ...; cleanup_owner

Native tasks vs. the removed legacy task-signal

Section titled “Native tasks vs. the removed legacy task-signal”

There are two distinct “task” concepts. The boundary is load-bearing.

Legacy signal(type=task) Native ExecutionTask
How created emit --type task tasks.create (chorus_task_create)
Status Removed — 422 LEGACY_TASK_EMIT_REMOVED Current
Shape a Signal row a typed execution row (adapter.ts:293)

The catalog command signals.emit.task exists only to reject. Its execute() throws LEGACY_TASK_EMIT_REMOVED with zero side effects (src/commands/handlers/signals.ts:53-58), and all four transports converge on that single rejection point.

Each entity carries its own state enum (src/db/adapter.ts:227-247):

Entity State field Values
ExecutionTask TaskState triage, todo, ready, blocked, done, cancelled, archived
ExecutionTask TaskExecutionState idle, in_flight, degraded
RunAttempt RunState 16 values: queued, claimed, running, awaiting_report, heartbeat_stale, stalled, completed, timed_out, crashed, interrupted, superseded, cancelled, reclaimed, gave_up, protocol_violation, spawn_failed
Delivery DeliveryState 13 values: dirty, committed, pushed, pr_opened, checks_pending, checks_passed, checks_failed, review_requested, changes_requested, approved, merged, dismissed, report_only
Checkout CheckoutState provisioning, ready, in_use, released, orphaned, failed

RUN_ACTIVE_STATES (adapter.ts:232) names the in-flight RunState values (queued…stalled); the complement is terminal. TASK_TERMINAL_STATES is done, cancelled, archived (adapter.ts:234). A Checkout also has a CheckoutKind (git_worktree, git_clone, container, devcontainer, vm, ssh, scratch_dir, local, external — adapter.ts:248).

  • ExecutionTask (adapter.ts:293-323) — {ring, title, body?, state, execution_state?, priority, capabilities[], base_repo?, base_branch?, graph_revision, blocked_by[], blocks[], assignee?, spawn_hint?, claim_lock?, claim_expires?, ...}. A minimal create needs only title + ring; the server infers created_by from the authenticated caller and defaults task_spec_revision to manual/v1 (adapter.ts:325-336). blocked_by and blocks are compatibility projections rebuilt from authoritative relation rows; generic task updates cannot write them.
  • RunAttempt (adapter.ts:346-380) — one per claim. Carries attempt_number, claimed_by_identity, executor_kind, claim_lock, claim_expires_at, heartbeat_at?, primary_delivery?, termination_reason?.
  • Delivery (adapter.ts:397-425) — the output of an attempt. is_primary, required_for_completion, optional pr (PullRequestRef), commits[], checks[], artifact_refs[].
  • Checkout (adapter.ts:430-449) — the sandbox the attempt runs in. kind, cleanup_owner (run_attempt / task / ring_janitor / external), state.

Each blocker is a durable task_dependency relation. A dependency points either to another task in the same ring or to an external object identified by {system, id}. The relation, not the ExecutionTask.blocked_by array, is the source of truth.

  • tasks.dependencies.add adds an edge and blocks the dependent in the same transaction when the edge is unsatisfied.
  • tasks.dependencies.update records a new state for an external dependency.
  • tasks.dependencies.remove removes an edge and re-evaluates the dependent.
  • tasks.graph.get returns bounded diagnostics: relation rows, direct dependents, the global graph revision, unsatisfied count, and authoritative readiness.

Task blockers are satisfied by done, cancelled, or archived. External observations use terminal states such as completed, merged, resolved, or satisfied. Completing only one of several blockers leaves the dependent blocked; the final satisfied or removed blocker moves an otherwise blocked task to ready. Self, missing, duplicate, cross-ring, and cyclic task edges fail without partial writes. Traversal and per-task edge counts are bounded.

Dependency authority cannot change underneath active work. Adding an unsatisfied edge, changing an external edge from satisfied to unsatisfied, or reopening a task blocker when the dependent has an active lease fails with 409 TASK_DEPENDENCY_ACTIVE_EXECUTION_CONFLICT. Run completion repeats the authoritative zero-unsatisfied-edge check inside its write transaction, closing the race between a stale dependency read and a terminal commit.

Migration 047 inventories legacy arrays, converts up to 100 safe entries per task to relation rows, quarantines missing/cyclic/ambiguous/over-limit entries, and rebuilds both compatibility projections atomically. A dependency whose bounded traversal cannot finish is quarantined as graph_limit_exceeded; it is not reported as cyclic unless a cycle was actually found.

Claiming a task opens a RunAttempt under a lease only when the task is ready and has zero unsatisfied authoritative relations. The lease TTL is taskClaimTtlMs() (adapter.ts:338-344): the TASK_CLAIM_TTL_MINUTES env var, or 15 minutes by default. tasks.heartbeat renews claim_expires. Expired leases are reaped on the background reaper tick and the run attempt is released (gap-3 §1.4).

Terminal window
chorus tasks create --title "first task" --ring dev
# -> task:... State: ready
chorus tasks claim task:... --claimed-by <your-identity> \
--transition-id claim-first-task-0001
# -> run_attempt:... State: claimed

Task claim, heartbeat, release, and cancel plus run progress, heartbeat, and termination require a caller-stable transition_id. Reuse it only for retries of the same logical mutation; a payload change under the same ID returns 409 EXECUTION_IDEMPOTENCY_CONFLICT. The task/run mutation, receipt, and webhook outbox row commit atomically. The in-process executionTransitionBus is then only a low-latency wake-up for webhook delivery; it does not recreate an already committed event.

Run progress follows a closed graph (queued → claimed → running, with bounded recovery paths through awaiting_report, heartbeat_stale, and stalled). Terminal states are accepted only by the terminal command. completed requires exactly one coherent ready primary delivery, every required delivery proof ready, and zero unsatisfied dependencies. Terminal disposition is explicit: completion marks the task done; cancellation marks it cancelled; retryable failures return it to ready until the retry limit, while gave_up/protocol_violation and exhausted failures block and degrade it. Every terminal/release/reap path clears active uniqueness authority.

Non-lifecycle task and graph changes still publish a {kind, from_state, to_state} record onto the executionTransitionBus; webhook delivery durably materializes those legacy/non-atomic events. Blocker state changes also emit transitions for each direct dependent whose relation-derived readiness changed. The SSE stream is not fed by this bus: it subscribes to the separate signalBus and emits Signal rows only (src/streaming/routes.ts:161).

See signals for the coordination plane and the core loop for the end-to-end create → claim → emit sequence.