Skip to content

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.

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 … → finish

Workflows are in runtime/workflows.py, their payloads and limits in runtime/contracts.py, and the activities that do the work in runtime/activities.py.

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.

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:

  1. Claim. The polo.runtime.claim.v1 activity sends a claim command and gets a Lease: the run reference, a fence and a version. The claim lasts CLAIM_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.
  2. Plan. The plan activity asks the model for a Plan (runtime/models.py): either up to MAX_CHILDREN (4) ProposedAgent entries with their dependencies, or one clarification question. Never both.
  3. Delegate and run. _run_plan starts a delegate as soon as everything it depends on has succeeded, up to the plan’s max_parallel at once. A delegate whose dependency failed is marked skipped.
  4. 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.
  5. Rework. If the review does not accept, the supervisor replans and runs another round, at most MAX_REWORK_ROUNDS (2).
  6. Finish. The finish activity sends complete_agent with the result.

The workflow code contains no model calls and no I/O. It decides order and waits; everything else is an activity.

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.

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.

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).

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.canceled outbox 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.

Money is reserved before it is spent and settled after.

  • Before each model call the runtime sends reserve_model_call with 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_call with the token counts and the cost, and status: 'settled'. If the outcome is not known (the provider timed out after accepting the request), it sends status: 'unknown' with no cost.
  • A run’s Budget therefore has four numbers: allocated, reserved, known and unresolved. Unknown spend stays in unresolved. It is never recorded as zero.
  • When a child finishes, chargeParent releases 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.