ADR 0182: Start tasks from Project task definitions
Orbit has one task model. A task is one row in thetasks table, and the subtasks of a task are its child tasks. Each Project stores task definitions in the Gateway database: predefined tasks with ordered subtasks, conditions between the subtasks, and the parameters they accept. Every task starts from a definition, either now or at a scheduled time. The Gateway code knows the task kinds but no Project’s definitions, so any Orbit user can define work for a Project without changing code, and an agent can change a definition through the API.
Status
In progress. Principle: this decision serves one way, one name and deterministic first. One model and one table hold every task and subtask, every task starts from a definition, and code decides every subtask outcome it can.Context
The Tasks engine stores two models: aTaskGroup, which the Tasks board shows, and its ordered Task rows, which the CLI and web app call subtasks. Both have a title, a brief, a status, assistance, comments, and metrics, and the scheduler advances both on the same tick. The engine runs one fixed flow for every group, and a Project’s policy lives in a repository skill such as orbit-tasks.
Other work does not fit that fixed flow. A maintenance run for an App needs these subtasks in order:
- Update the dependencies, and fix what the update breaks.
- Run the tests and verify the App in a browser.
- Merge the pull request.
- Deploy the production Instance.
- Verify production, and roll back when verification fails.
Decision
The Gateway owns the task model, the task kinds, routing, and schedules. Each Project owns its task definitions as data.One task model
ATask is one row in the tasks table. A task without parent_id is a top-level task: it is what the Tasks board shows, and it holds the parameters of its start. A task with parent_id is a subtask of that parent. A subtask has no subtasks of its own.
Both levels share the title, brief, status, outcome, assistance, comments, and metrics. When a top-level task ends, Orbit writes the totals of its subtasks (tokens, line diff, and duration) to its own row, so the board reads one row per task. The Gateway enforces which columns each level uses.
The run receipt becomes the turn receipt:
.git/orbit/turn ends an agent’s turn, so “run” names nothing else in Tasks.
The TaskGroup model, its table, and the separate subtask table are removed. Top-level tasks keep the ids of their groups, so task identities such as ORB-177, the task-{id} branches, and pull request titles stay valid. Subtasks get new ids, and every reference to a subtask moves with it. There is no compatibility path for the old names.
Task definitions
A task definition belongs to one Project and is a Gateway record. It has these fields:
A subtask definition has
key, title, kind, and optional brief, phase, deliverables, and routes, plus the fields of its kind:
key is unique within the definition. complete and fail are reserved for route ends, so a subtask cannot use either name. deliverables follow the Tasks deliverables contract.
A subtasks parameter inserts the subtasks that the starter passes at that point in the list. Every inserted subtask is an agent subtask. Orbit’s own feature work uses it: the “Build a feature” definition has a fixed documentation subtask and a subtasks parameter for the implementation subtasks that the ADE plans.
Start a task
Every task starts from a definition. There are two ways:- Now. A caller starts a definition with its parameter values. Orbit creates the top-level task in the definition’s
statusand creates its subtasks. - At a time. A definition with a
schedulealways has exactly one upcoming task. The Tasks scheduler tick keeps it inbacklog, withscheduled_atset to the next time the schedule matches, and the board shows it as scheduled. A caller can also start a definition once at a givenscheduled_at.
scheduled_at has passed, the tick creates the subtasks from the definition as it is at that moment, moves the task to todo, and creates the next upcoming task. A definition edited between the two therefore takes effect on the next run. Three rules keep a schedule predictable:
- A due task waits in
backlogwhile an earlier task from the same definition is still active, and moves totodowhen that task ends. - After downtime, a definition starts one task, not one per missed time.
- Removing or changing a schedule deletes the upcoming task or replaces it with one at the new time.
todo.
Task kinds
The kind decides who works on a subtask and which outcome it ends with. The task and subtask statuses stay the same for every kind.
Exit code 125 means skipped, as it does for
git bisect run. A check or action subtask spends no model tokens, and a decide subtask makes one classification call.
An action subtask can call only an operation that the OpenAPI document marks with x-orbit-task-action: true. This decision marks instance:deploy and instance:rollback. Marking another operation needs its own decision. Only a Gateway-access caller can write a definition, so a definition cannot grant a caller more authority than it already has.
A decide subtask’s evidence names subtasks before it by key. For each one, Orbit sends its outcome, its deliverable results, the last 200 lines of each command’s output, and the diff summary of an agent subtask. min_probability is between 0 and 1 and defaults to 0.8. Every call is stored as a Jev decision record with the purpose task_route. A failed Jev call asks for assistance, as a low probability does.
Each kind is one Gateway class that implements one contract, registered as agent drivers are: start begins the work, observe reads its progress on a tick, cancel stops it, and outcome returns the outcome and its evidence. The scheduler knows only this contract. A kind declares its fields and outcomes, and definition validation uses that declaration. A new kind needs a new lifecycle, such as waiting for a person or an outside event, and its own decision. Kinds stay in code; definitions stay in data.
Routes
A subtask’sroutes map each of its outcomes to a target: the key of a subtask after it, complete, or fail. complete and fail are reserved subtask keys, so a route target with either name is always the end and never a subtask. A missing route uses the default:
A
decide subtask has no defaults. Its routes must name a target for every option.
A route points only forward, so a task cannot loop and always ends. The fix loop inside an agent subtask, where the reviewer requests changes, stays inside that kind. Orbit cancels each subtask that a route passes over. complete ends the task as it ends today: it settles while its pull request is open and completes when the pull request merges. fail fails the task and asks for assistance, with a reason that names the subtask and its outcome.
Validation
The Gateway validates a definition on every write and refuses an invalid one with HTTP 422tasks.definition_invalid. details names each failing rule with the subtask key it concerns. It refuses:
- a duplicate subtask key, a subtask key of
completeorfail, an unknown kind, a field that the kind does not declare, a missing field that the kind requires, or an empty model name; - a route from an outcome that the kind does not declare, or to an unknown key, to the subtask itself, or to a subtask before it;
- a
decidesubtask without a route for each option; - a subtask that no path from the first subtask reaches;
- a subtask whose
phaseis not inphases, or a phase whose subtasks are not next to each other; - a duplicate phase key;
- an
actionoperation that is not marked as a task action; - more than 100 subtasks, 50 parameters, 50 phases, 50 arguments on one subtask, or 100 schedule values;
- a
{parameter}thatparametersdoes not declare, a duplicate parameter name, more than onesubtasksparameter, or a schedule value for a parameter that the definition does not declare; - a cron expression that is not five valid fields, or a schedule without a value for each required parameter.
tasks.definition_exists. Starting a definition with a missing or wrongly typed parameter returns HTTP 422 tasks.parameters_invalid.
An empty model name is refused. Any other model name is not checked against ProxyCli’s list, because that list changes over time. When that list is available, the web app reports a model that no driver can run as a finding. A model is known when ProxyCli offers it through a provider Pi runs. A Claude model is not known. Tasks: Task agents run on Pi explains why Claude is unavailable for task agents. When the list is missing, empty, or refused, the view says that the model list is unavailable and reports no driver findings.
API
Update replaces the whole definition, so an agent edits one by reading it, changing it, and writing it back. The update body may omit
name; the path supplies it. A body name that differs from the path is refused. Every operation refuses with HTTP 409 extension.disabled while the extension is off, the same code as the other task operations. The CLI commands take a definition as a JSON file, and the MCP tools are generated from these operations. The existing task operations keep working on top-level tasks, and the subtask operations work on subtasks.
GET /api/v1/proxycli/models (proxycli:models) lists the models that CLIProxyAPI offers, as {id, provider}. On each poll the collector reads GET /v0/management/auth-files/models for each auth file, with the management key, and stores the union in its snapshot. When one auth file’s request fails, that file keeps the models from the previous poll. It does not call GET /v1/models: that route checks a client API key, and the management key is not one. provider is the ProxyCli provider that serves the model: owned_by openai is codex, anthropic is claude, xai is grok, moonshot is kimi, and any other value keeps its name. google and meta keep their names, and no ProxyCli provider serves them. When owned_by is absent, the collector maps the auth file’s provider with that same table. The management list sets provider and type to one value. A model with neither owned_by nor an auth-file provider is omitted. The route refuses with proxycli.disabled while the fleet feature is off.
Definition view
The web app lists definitions on the Tasks page and on each Project page, and draws a definition on a canvas from the live API. The drawing shows:- the main path down the middle, and detours and failure paths in side columns, each ending in its own
completeorfailnode; - each subtask’s kind, models, and routes with their outcome labels; default routes to an end stay hidden;
- each phase as one card that opens into a frame around its subtasks;
- the engine’s fixed stages around the definition: starting the workspace before the first subtask, the implementer, handoff check, and reviewer inside each
agentsubtask, and the pull request, the merge by a person unless the definition has amergesubtask, and the cleanup after the last subtask; - the schedule in words, such as “Weekly on Monday at 03:00 UTC”;
- findings: a
decidesubtask whose options all lead to one subtask, and, when the ProxyCli model list is available, a model that no driver can run. The Gateway refuses a definition that contains a subtask no path reaches, so the view does not report that finding for a stored definition. A missing, empty, or refused list is one notice and no driver findings. The drawing opens at full size, and a side path is reached by panning.
scheduled_at.
Changes to project-owned task policy
This amends project-owned task policy. A Project’s task policy lives in its task definitions in the Gateway, not in a repository skill. Theorbit-tasks skill keeps only guidance for an agent that writes briefs and deliverables. Orbit’s feature work becomes the “Build a feature” definition described above, starting in backlog, where the ADE fills in the parameters.
Slices
Each slice is one task and one pull request. This record stays until the last slice absorbs it into the Tasks reference.Example: App maintenance
The definition has an
app parameter and the schedule 0 3 * * 1 with that App. Every Monday at 03:00 UTC, its upcoming task leaves Backlog, and Orbit updates the App, merges, deploys, and verifies it without an operator. A week without updates ends after update. A minor upgrade skips review. A failed production check rolls back, fails the task, and asks for assistance.
Rejected alternatives
- Separate models for a run and its subtasks, such as
TaskRunandStepRun, or keepTaskGroupbesideTask: a task and its subtasks share their lifecycle and most fields, and two models give one concept two names. The columns that only one level uses are few, and most per-subtask data already lives in its own tables. - Let subtasks have their own child tasks: no known definition needs it, and it raises open questions about workspaces and pull requests per level. A separate decision can add a task kind that starts another definition.
- Tasks without a definition: that keeps a second way to create work. Ad hoc work uses a definition with a
subtasksparameter. - A separate schedule record, or Orbit’s host Schedules: a host Schedule runs a command on a Node, which would need the CLI and Gateway credentials there, and Tasks cannot see a timer that failed on that Node. The upcoming task in Backlog makes the next run visible on the board.
- Copy the subtasks when the upcoming task is created: an edit to the definition during the week would then miss the next run.
- Extract Tasks into a separate package, or use n8n as the engine: the engine depends on Instances, agent threads, realtime, and the GitHub App, and flows would live in two systems. Orbit builds no connectors for other services; a separate decision can add a generic webhook kind.
- Keep definitions as files in each Project’s repository: a Project without a repository cannot have one, and the Gateway cannot validate or schedule a file it does not hold.
- A general workflow engine with backward jumps, conditions in expressions, and parallel branches: no known definition needs them, and forward routes always end.
- Store the engine’s fixed stages, such as the workspace start and the pull request, in each definition: every definition would repeat them, and a definition would then decide what the engine guarantees.
- Special fields such as
stop_when_unchangedandon_failure: theskippedoutcome and afailedroute cover both. - Route with an
agentsubtask: an agent session costs far more than one classification and returns prose that code must parse. - Let an
actionsubtask call any operation: a definition would then carry destructive operations with no review. - Load task kinds from the database: a kind is executable behavior, and storing code as configuration crosses the Gateway’s trust boundary.
Consequences
- Any Orbit user can define, start, schedule, and see a Project’s work through the API, CLI, MCP, and web app, with no code change.
- Merging the two task tables is one migration without a compatibility layer. Every part of the Gateway, CLI, SDK, and web app that reads groups or subtasks moves to the one model.
- The scheduler must run subtasks through the kind contract and follow routes. Today it assumes that every subtask has an implementer, a reviewer, and a diff, and that subtasks run in position order. Separating those assumptions is the main cost of this decision.
- The Gateway GitHub App needs permission to merge pull requests in the Project repositories that use a
mergesubtask, and amergesubtask merges without a human review. - Exporting a definition from one Gateway and importing it on another needs its own decision.
Affects
- Components: apps/gateway, apps/cli, packages/php-sdk, apps/web
- ADRs: amends project-owned task policy
- Detail: Tasks, proxycli, Web app, Tasks CLI, Concepts
- Verify: slice 1: the migration test on a copy of the task tables and the existing task suite on the one model; slice 2: tests for each validation rule and definition operation, the proxycli models route, CLI and SDK fixtures, and web screenshots; slice 3: tests for starting now,
scheduled_at, the upcoming task, the three schedule rules, and parameters; slices 4 to 6: tests for each kind and its outcomes and thedecidethreshold, and an Incus proof of the maintenance example, including theskippedpath and a forced rollback