Task lifecycle
This page follows one task through the code. The user-facing version of the same story is on How Polo works. Here the names are the ones you will find when you grep.
The short version
Section titled “The short version”POST /api/tasks ─▶ task + root run + outbox event "task.created"relay ─▶ starts workflow polo.case.v1 (CaseWorkflow)CaseWorkflow ─▶ claim → plan → delegate → review → finish each delegate ─▶ child workflow polo.agent.v1 (AgentWorkflow) AgentWorkflow ─▶ claim → step, step, step … → finishWorkflows are in runtime/workflows.py, their payloads and limits in runtime/contracts.py, and the activities that do the work in runtime/activities.py.
1. Intake
Section titled “1. Intake”A task is created by a member (POST /api/tasks), by an intake source such as Slack or a Grafana alert, by a schedule, or through Polo’s MCP server. All of them end in WorkService.createTask or createSupervisedTask (api/src/modules/work/application/work.service.ts).
One transaction writes the task, its root run, and an outbox event of kind task.created. The task pins its policy at this moment: a TaskPolicy copy of the agent’s profile (grants, model routes, budget, limits, guardrails). Later profile edits can only narrow a running task, because the grants a run may use are always the intersection of what was pinned and what the profile still holds (currentTaskGrants).
Task and run statuses are one type, WorkStatus: queued, running, succeeded, failed, canceled.
2. The supervisor workflow
Section titled “2. The supervisor workflow”The relay (api/src/platform/temporal/outbox-relay.ts) starts polo.case.v1 with a WorkRef (tenant_id, task_id, run_id) and the workflow ID polo-case-v1:<tenant>:<run>. Duplicate starts are rejected by ID, so at-least-once delivery is safe.
CaseWorkflow.run then does this:
- Claim. The
polo.runtime.claim.v1activity sends aclaimcommand and gets aLease: the run reference, afenceand aversion. The claim lastsCLAIM_SECONDS(180) and is renewed before every long step. Every later command must carry the fence, so a worker that lost its claim cannot write. - Plan. The plan activity asks the model for a
Plan(runtime/models.py): either up toMAX_CHILDREN(4)ProposedAgententries with their dependencies, or one clarification question. Never both. - Delegate and run.
_run_planstarts a delegate as soon as everything it depends on has succeeded, up to the plan’smax_parallelat once. A delegate whose dependency failed is markedskipped. - Review. The review activity asks the model for a
ReviewDecision. Accepting requires at least one cited evidence reference; the type refuses an acceptance without one. - Rework. If the review does not accept, the supervisor replans and runs another round, at most
MAX_REWORK_ROUNDS(2). - Finish. The finish activity sends
complete_agentwith the result.
The workflow code contains no model calls and no I/O. It decides order and waits; everything else is an activity.
3. The delegation contract
Section titled “3. The delegation contract”The supervisor does not create agents. It proposes them, and the control plane decides.
The delegate command carries a DelegationProposal (api/src/modules/work/domain/work.ts):
| Field | Meaning |
|---|---|
role, objective, reason |
What this specialist is for and why it is needed |
acceptance |
The criteria its result is judged against |
output_contract_id |
Always polo.evidence.v1 today |
context_refs, depends_on |
Inputs and the runs that must finish first |
grants |
The tools it asks for: { capability_id, resources[] } |
model_class |
A class name from the profile, not a provider endpoint |
budget_microusd, max_tokens, max_tool_seconds |
Its spending and time limits |
deadline |
When it must be done |
selectChildPolicy validates the proposal against the parent run and the current profile. It refuses a child that would exceed the delegation depth, ask for more budget than the parent has left, raise the token or tool-time limits, set a deadline past the parent’s, name an unregistered model class, or request any grant outside the intersection of the parent’s grants and the profile’s. A child can never hold more than its parent.
If it passes, the API writes an immutable AuthorizedAgentSpec: the proposal plus server-assigned identity (run_id, agent_id, parent_run_id), the resolved model_route_id, profile_revision, depth, workspace_mode, the owner’s instructions and guardrails, and a digest. The child’s budget is reserved from the parent’s in the same transaction.
The model cannot choose executable code, credentials, tenant identity or permission policy. None of those are fields it can fill.
4. A specialist run
Section titled “4. A specialist run”CaseWorkflow starts each delegate as a child workflow polo.agent.v1 with ID polo-agent-v1:<tenant>:<run> and ParentClosePolicy.REQUEST_CANCEL.
AgentWorkflow.run claims its run, then loops up to MAX_AGENT_STEPS (24) times. Each iteration runs the step activity, which builds the prompt from the spec, asks the model for one AgentDecision, and acts on it:
| Decision | What happens |
|---|---|
tool or invoke |
A sandbox action, a read, or a proposed operation. The observation is committed with commit_observation. |
ask |
A clarification question is recorded; the workflow waits for the answer. |
wait |
A durable timer of 30 to 900 seconds, at most MAX_WAITS (6) per run. |
finish |
The run ends as succeeded or failed with a summary and its uncertainty. |
The step returns a StepReady to the workflow, which tells it whether to wait and on what.
5. Questions and approvals are waits
Section titled “5. Questions and approvals are waits”Neither a question nor an approval blocks a thread. Both are durable waits inside the workflow.
Questions. When a step or a plan returns a question_id, _wait_for_question renews the claim, polls the question, and sleeps with a growing interval (15 seconds up to 90) until it is answered or the run’s deadline passes. A person answers at POST /api/tasks/:taskId/questions/:questionId/answer. An answer adds context only; it grants nothing.
Approvals. When a step proposes a change, it returns an operation_id. _wait_for_operation polls the operation while its status is awaiting_approval or running. When someone decides, the relay sends the signal operation_decided to wake the workflow early. The signal carries no authority: the activity reads the recorded decision from the API. If the signal is lost, the next poll finds the decision anyway.
A wait that outlives the run’s deadline ends the run with a plain reason, such as “Ran out of time while waiting for someone to decide on a proposed change.” (runtime/stop_reasons.py).
6. Cancellation
Section titled “6. Cancellation”POST /api/tasks/:id/cancel calls WorkService.cancel. In one transaction it:
- sets every unfinished run and the task to
canceled, - clears each run’s
attempt_fence, so any command still in flight from a worker is refused, - closes open model calls and the change requests still waiting for approval,
- writes a
task.canceledoutbox event.
The relay then cancels the Temporal workflow, and REQUEST_CANCEL propagates to its children. The order matters: product state is fenced first, so nothing a late activity sends can change it.
Runs that no worker can end are swept separately. endOverdue fails a run that is one claim period past its deadline with no live claim, and reports it as out of time.
7. Cost settlement
Section titled “7. Cost settlement”Money is reserved before it is spent and settled after.
- Before each model call the runtime sends
reserve_model_callwith the most the call can cost and the route’s tariff. If the run’s budget cannot cover it, the call does not happen. - After the call it sends
settle_model_callwith the token counts and the cost, andstatus: 'settled'. If the outcome is not known (the provider timed out after accepting the request), it sendsstatus: 'unknown'with no cost. - A run’s
Budgettherefore has four numbers:allocated,reserved,knownandunresolved. Unknown spend stays inunresolved. It is never recorded as zero. - When a child finishes,
chargeParentreleases the child’s reservation from the parent and adds the child’s known and unresolved cost to the parent’s. - A route with no price (a local model, a subscription) records its calls as
unpriced. Those are counted in tokens and bounded by the token limit, not shown as $0.
The final AgentResult carries known_cost_microusd and unresolved_cost_microusd side by side, with summary, artifacts, evidence, checks and uncertainty.
Where to go next
Section titled “Where to go next”- The operation contract covers what happens between “proposes a change” and “verified”.
- Tools and runners covers what a
tooldecision can reach. - States lists every status word used above.