Operate

Mode-Aware Tasks

Keep one task name per intent while native, container, and remote execution stay honest about context, lifecycle, env, and runtime.

learnnew usersintermediatestable2026-04-22

Why this feature exists

Mode-aware tasks let one task keep one meaning while its execution shape changes honestly by backend.

That matters because users think in intents like start, build, test, or dev, not in permanent task-name forks such as start, start:host, and start:container.

  • Use one task name per intent so CLI and docs remain stable while execution truth stays backend-aware.
  • Use --mode for run-time plane selection; task identity should not change with execution backend.
  • Use native, container, and remote branches for the exact env, lifecycle, and runtime differences that actually change behavior.
  • Keep naming stable and place the mode truth in contract branches instead of spreading it across aliases.

What it fixes

Before

Split task names drift

Repos end up with start, start:host, start:container, or other local naming inventions just to express backend differences.

After

One task, honest branches

A single task can declare different context, lifecycle, env, command body, and runtime listener behavior per mode.

Result

Better UX and cleaner contracts

Humans, CI, and agents can call the same task while Ota owns the backend-specific truth instead of pushing that burden into README notes or task aliases.

The operator model

The mental model should stay simple.

Users choose the task intent first. Ota then resolves which execution branch should own that one run.

  • ota run <task> resolves to tasks.<name>.execution.default_mode first, so normal operators can stay on one task name.
  • Use ota run <task> --native when you need the host branch without changing task identity (--mode native equivalent).
  • Use ota run <task> --container when you need the container branch and its declared wiring (--mode container equivalent).
  • Use --lifecycle for one invocation when container reuse behavior must change for that run, or use --ephemeral / --persistent as shorthand.
  • If the requested mode has no branch, Ota falls back to the task-level execution body and task-level execution settings instead of forcing redundant empty branches.
The clean UXbash
ota run startota run start --nativeota run start --containerota run clean:start

When to use it

  • Use it when intent is stable but host and container execution differ in networking, environment, or runtime behavior.
  • Use it when host and container paths reach different service endpoints (for example 127.0.0.1 versus service DNS postgres).
  • Use it when container dev should be persistent, but a clean or alternate mode should intentionally be ephemeral.
  • Use it when only container mode should publish ingress while preserving the same task name.

When not to use it

  • Do not use it when intent changes; keep separate task names for separate user operations (for example start vs clean:start).
  • Do not use it when there is only one real execution path; mode-aware branches should model meaningful alternatives.
  • Do not use it to mask unclear semantics; keep each mode branch explicit about exactly what changes.

Contract anatomy

The task-level execution block is where backend-specific truth belongs when one task should span more than one plane.

That branch can own context, lifecycle, env, run or script, and runtime.

One task with native and container branchesyaml
tasks:  start:    description: Start the local development environment    requires_services:      - postgres    execution:      default_mode: container      modes:        native:          context: host          env:            DB_URL: jdbc:postgresql://127.0.0.1:5432/app          run: mvn spring-boot:run         container:          context: app          lifecycle: persistent          env:            DB_URL: jdbc:postgresql://postgres:5432/app            SERVER_ADDRESS: 0.0.0.0            SERVER_PORT: "8080"          run: mvn spring-boot:run          runtime:            kind: service            listeners:              http:                protocol: http                bind:                  address: 0.0.0.0                  port:                    mode: fixed                    value: 8080                project:                  host:                    address: 127.0.0.1                    port:                      mode: auto                    path: /

Why this is better than split task names

Separate task names are still valid when semantics differ.

But when the only difference is backend-specific execution shape, split names push Ota's job back onto the user.

  • Use one task with explicit mode branches instead of alias-like task names for host/container variants.
  • One intent should drive one task while each branch captures one truthful execution plane.
  • Agents stay deterministic because they infer one canonical task and then resolve mode, not alias precedence.
  • Docs and examples stay cleaner when task names do not change with backend choice.

Real operator scenarios

The best use cases are not abstract schema tricks.

They are the places where a repo has one obvious user intent but more than one honest execution plane.

Scenario 1: One start task

A backend API often needs two real paths.

Engineers want host execution for IDE debugging and local iteration, while the team also wants a container-backed path with service-network DNS, pinned toolchains, and the same ingress story used everywhere else.

The task intent is still just start, so this should not turn into start, start:host, and start:container forever.

  • Use this when both host and container development are first-class user paths.
  • Keep host wiring explicit in the native branch (for example 127.0.0.1 database endpoints).
  • Keep container wiring explicit in the container branch (postgres service DNS and container ingress rules).
Scenario 1yaml
tasks:  start:    requires_services:      - postgres    execution:      default_mode: container      modes:        native:          context: host          env:            DB_URL: jdbc:postgresql://127.0.0.1:5432/app          run: mvn spring-boot:run         container:          context: app          lifecycle: persistent          env:            DB_URL: jdbc:postgresql://postgres:5432/app            SERVER_ADDRESS: 0.0.0.0            SERVER_PORT: "8080"          run: mvn spring-boot:run # operators use:# ota run start# ota run start --mode native

Scenario 2: One dev task

A frontend team may want the normal dev loop to run inside the app image so Node, package-manager behavior, and native binaries stay pinned.

But engineers still sometimes need a host override for fast local debugging or to compare behavior outside the container boundary.

The task should stay dev. The backend choice should be what changes.

  • Use this when container execution is the default dev loop.
  • Put host projection and browser URL behavior in the container branch so it is explicit and testable.
  • Keep host override available through --mode native instead of introducing permanent task aliases.
Scenario 2yaml
tasks:  dev:    execution:      default_mode: container      modes:        native:          context: host          run: npm run dev        container:          context: app          lifecycle: persistent          run: npm run dev          runtime:            kind: service            listeners:              http:                protocol: http                bind:                  address: 0.0.0.0                  port:                    mode: fixed                    value: 3000                project:                  host:                    address: 127.0.0.1                    port:                      mode: auto                    path: / # operators use:# ota run dev# ota run dev --mode native

Scenario 3: One build task

A build task often has two honest goals.

Developers want a fast native compile for feedback, while the release path may need a Linux container with a pinned target and toolchain so shipped artifacts are reproducible.

The task meaning is still build, so the backend should vary without changing the task name.

  • Use this when release artifacts require strict, pinned container execution.
  • Keep native build optimized for local iteration speed.
  • Keep container build strict for reproducible targets and artifact shapes.
Scenario 3yaml
tasks:  build:    run: cargo build    execution:      default_mode: native      modes:        container:          context: release          lifecycle: ephemeral          run: cargo build --release --target x86_64-unknown-linux-gnu # operators use:# ota run build# ota run build --mode container

Lifecycle is part of the feature, not an afterthought

Mode-aware tasks are not just about run and env.

Lifecycle belongs in the same place because container reuse semantics are part of the execution truth.

  • Use persistent for fast local loops where container reuse is desirable.
  • Use ephemeral when a branch must start from a clean runtime state.
  • Accept that native mode may be lifecycle-neutral and document that explicitly.
  • Keep lifecycle on branch scope so host and container semantics never leak into each other.
Clean slate stays a separate taskyaml
tasks:  clean:start:    description: Start from a clean slate    depends_on:      - compose:down      - stop      - build    execution:      default_mode: container      modes:        container:          context: app          lifecycle: ephemeral          env:            DB_URL: jdbc:postgresql://postgres:5432/app            SERVER_ADDRESS: 0.0.0.0            SERVER_PORT: "8080"          run: mvn spring-boot:run

Decision guide

  • Use a mode-aware task when one user intent must be preserved across backend changes.
  • Use separate task names only when behavior changes, not only execution plane.
  • Keep backend-specific truth in each branch (context, lifecycle, env, command body, and runtime) and keep intent fields stable.
  • Set execution.default_mode as the normal operator path; branches become explicit exceptions.
  • Treat --mode as the one-run override, not as a replacement for a clear default-mode story.

Why this sells Ota

This is one of the clearest examples of Ota being more than a task runner.

Ota is not just launching commands. It is owning execution truth.

  • One task name stays stable while execution planes evolve without breaking operators.
  • Context, lifecycle, environment, and endpoint behavior remain explicit instead of drifting into conventions.
  • A single repo can keep host development, container reproducibility, and remote paths without renaming tasks.
  • Humans and agents get one contract-first execution story, not repo-specific escape hatches.