Skip to content

The Task Lifecycle

joshdaugherty edited this page Sep 26, 2026 · 5 revisions

Describes robot-council/core v0.7.1.

A task is one unit of work in the fleet's queue. It moves through seven statuses by eight transitions, and every transition is one conditional database write: the statuses it may start from, the holder it requires, and the claim eligibility rule are all in the write's where, so two agents racing for one task are settled by the database, not by whoever read first (Tasks.php, #25). Which session holds which ability is covered in Roles.

Statuses

Status Meaning Held by a session Terminal
pending Waiting to be claimed no no
claimed Held by one session, which has not started work yes no
in_progress Being worked by the session that holds it yes no
blocked Held, and waiting on something outside the session's control yes no
done Finished successfully no yes
failed Finished unsuccessfully no yes
cancelled Called off by a coordinator no yes

Nothing leaves a terminal status (TaskStatus.php).

Transitions

Call a transition with the MCP tool task_<transition> or with POST {prefix}/api/tasks/{task}/{transition}. An unknown transition name is a 404 from the router (routes/api.php, TaskTransitionTool.php).

Transition Starts from Ends in Ability Who may do it Event
claim pending claimed tasks:claim Any session eligible under #16 (below) task.claimed
start claimed, blocked in_progress tasks:claim The holder task.started
block claimed, in_progress blocked tasks:claim The holder task.blocked
complete claimed, in_progress done tasks:claim The holder task.completed
fail claimed, in_progress, blocked failed tasks:claim The holder task.failed
release claimed, in_progress, blocked pending tasks:claim, or coordinator:direct The holder, or any coordinator task.released
reassign pending, claimed, in_progress, blocked claimed coordinator:direct A coordinator task.reassigned, plus a placement.instruction and a directive
cancel pending, claimed, in_progress, blocked cancelled coordinator:direct A coordinator task.cancelled

Source: TaskTransition.php. The ability is checked in the controller rather than on the route, because release has two ways in (TransitionTaskController.php).

  • Of the holder's transitions, a coordinator may only release a task it does not hold. It cannot start, block, complete or fail one on another session's behalf. reassign and cancel are the coordinator's own transitions and need no hold (TaskTransition.php).
  • start takes an optional branch. Omitting it leaves any recorded branch alone, so resuming a blocked task keeps its branch (Tasks.php).
  • start also takes an optional sub_label, naming which of the lane's subagents took the task up. No other transition records one (the MCP tool refuses it on the others). Omitting it leaves any recorded label alone. See Sub-labels (#409, TransitionTaskController.php, TaskTransitionTool.php, Tasks.php).
  • complete and fail take an optional result object, bounded in size. No other transition records one (TaskTransition.php, TransitionTaskController.php).
  • Anything that returns a task to pending wipes it clean. The holder, claim time, placement record, hand-back flag, branch, and sub-label are all cleared (Tasks.php, #409).
  • The event body is Task #N <verb>. Its meta holds task_id and to, plus assigned_to on claim and reassign, and sub_label when the task had one (Tasks.php, #409). Which label each event carries is under Sub-labels.

Answers

HTTP Meaning
200 Applied. The body is {task_id, status, applied: true}, plus warnings on a reassign.
403 The task is in a status this transition starts from, but this session may not do it: you lack the ability the transition needs, you don't hold it, or it fails the #16 eligibility rule.
404 No such task.
409 The task is not in a status this transition starts from. This includes the case where somebody else moved it first. Read it again before retrying.
422 Invalid input, or a placement refused by an invariant (see Placement).

Source: Outcome.php, Tasks.php. The MCP tools return every refusal as an error result rather than a structured success (TaskTransitionTool.php).

Who may claim a task (#16)

A session may claim a task only if one of these is true (#16, Task.php):

  • the task belongs to the session's own developer, or
  • the task was created by a session that held coordinator:direct at the time.

The owning developer is copied from the creating session, never taken from the request. Whether the creator was a coordinator is recorded when the task is created, so revoking that ability later does not narrow who may claim the task (Tasks.php).

Every agent sees that every task exists. A task's title, description, payload, result, issue, and branch are shown only to a session that could claim it, or that holds coordinator:direct. Everyone else sees the row with those fields null and readable: false (TaskList.php).

Placement (reassign)

A placement is a coordinator handing a task to a named lane, which is a session. It is the reassign transition, and it can start from pending as well as from any held status, so a coordinator can place unclaimed work as well as move held work (#316, TaskTransition.php).

Arguments

Argument Required Meaning
session_id yes The lane to hand the task to. It must still exist and not be gone. A stale lane is accepted.
directive yes Your instructions to the lane, up to the event body limit.
expect no The status you read the task in. It must be one reassign starts from. Pass pending when placing unclaimed work.
hand_back no true when you are returning a gate's pull request to the lane that made it, rather than placing new work.

Sources: TransitionTaskController.php, TaskTransitionTool.php, TaskList.php.

  • A directive is required. A placement without one is refused, whether it arrives through the endpoint or through a direct call to the store. A lane holding work nobody told it about was the failure #316 removed (#316, Tasks.php).
  • expect is checked in the write itself. If a lane claimed the task after you read it as pending, the placement answers 409 and takes nothing from that lane. Only reassign accepts expect. Any other transition refuses it with a 422 (#328, #340, Tasks.php).
  • The lane must pass #16's rule, exactly as if it were claiming the task itself. If it fails, the answer is 403 (Tasks.php).
  • The lane is re-read under a lock. A lane that went gone between validation and the write gets a 409 (Tasks.php).
  • The task records how it came to be held. A placement stores placed_by: coordinator and the hand_back flag, and clears the branch. It also clears the sub-label, unless it places the task back on the lane already holding it. A lane's own claim stores placed_by: lane (Tasks.php).

Lane capacity (#409)

A lane may hold more than one task when it works through subagents. How many is its capacity, and a placement is refused with lane_free once the lane already holds that many other tasks (#409, Capacity.php, PlacementRules.php).

  • The session declares a number when it joins. POST {prefix}/api/sessions takes an optional capacity. A number above 16 is stored as 16. 0, a negative number, or anything that is not a whole number is refused with 422. A session that sends none declares 1, as every session did before v0.7.0 (SessionStartController.php, AgentSessions.php).
  • The seat's developer caps it, from 1 to 16, with the Tickets at once field for each seat on {web prefix}/dashboard/seats. The cap is 1 until they change it, and a session can never raise it (Seats.php, SeatSettings.php).
  • The capacity in effect is the smaller of the two, computed on every read rather than stored. A lane with no recorded seat has a capacity of 1. Raising the cap reaches a running session without a restart, and lowering it stops the next placement at once.
  • Where to read it. The join response and GET {prefix}/api/agent/session both return capacity (in effect) and declared_capacity (asked for, within 16). sessions_list and GET {prefix}/api/lanes return capacity for each session, and the session.joined event carries meta.declared_capacity (AgentSessionController.php, LiveSessions.php). The lane board shows each lane's occupancy as held over capacity, such as 2 / 3, and lists every task a working lane holds (LaneBoard.php).
  • Only a placement is checked against it, but every task the lane holds counts toward it, including ones it claimed itself. The task being placed is not counted, so re-placing work on the lane that already holds it does not count that task twice. A lane's own claim is not limited by capacity, as before.
  • lane_free keeps its name, its refusal text, and its waivers. At a capacity of 1 the rule is exactly the one it replaced (PlacementRule.php).

Invariants: refused, waived, or warned (#320)

A placement is checked against six hard rules. The rules are assessed before the write and enforced after it, inside the placement's transaction. If any broken rule is not covered by a waiver, the placement is rolled back and the answer is a 422 whose refused array lists every uncovered rule as {rule, reason}, not just the first. A placement that loses its race (a 409 or 403 from the write itself) reports no refusals (#320, #345, PlacementRules.php, PlacementRule.php).

Rule Refused when
ticket_open The task names an issue that is closed, or that the fleet has no record of
lane_in_repository The task names an issue and the lane's repository is not that issue's repository (compared without case)
lane_free The lane already holds as many other tasks as its capacity, which is 1 unless it declared more and its seat allows it
lane_not_parked The lane's seat is parked
ticket_unblocked The issue has a blocked_by edge whose blocker is open or unknown
assignment_hours The placement is new work and it is outside the lane's developer's assignment hours
  • Three rules apply only to a task that names an issue: ticket_open, lane_in_repository, and ticket_unblocked.
  • A coordinator reads the hours before placing, with developer_settings (since v0.7.1). The tool needs coordinator:direct and returns what GET {prefix}/api/developers/settings returns: each developer's hours and days off, and each seat's parked, exempt and max_capacity values. Each developer and seat also carries inside_hours (true, false, or "ungated" when no hours apply) and next_opens_at when it is false, computed by the same code this rule refuses with, so a seat read as inside its hours is not refused for being outside them. Settings change on the developer's page at any time, so read them when deciding rather than remembering (#440, DeveloperSettingsTool.php, CoordinatorSettings.php).
  • For assignment_hours, "new" is decided by the service, not by the coordinator. Placing a pending task is new. Moving a held task is also new unless the current holder belongs to the same developer as the lane. A hand-back is exempt only if this lane has itself started the task before, as recorded by its own task.started event. A seat marked hours-exempt is never gated.
  • Only the developer who owns the lane's seat can waive a rule, one rule for one placement, from {web prefix}/dashboard/seats. A coordinator cannot waive. The placement spends the waiver inside its own transaction, and only when the placement actually succeeds. A lane with no recorded seat has no waivers (PlacementWaivers.php, Tasks.php, routes/web.php).

A successful placement also returns warnings, which never block (PlacementRules.php, #344):

  • the title contains a word that needs a human (delete, remove, retire, release, tag, publish, install, upgrade, rotate, spend);
  • every acceptance-criteria checkbox on the open ticket is ticked;
  • the ticket is labeled documentation while functionality tickets in the same repository are placeable;
  • a branch whose name carries the ticket's number exists with no open pull request. This is matched by name, so it may be unrelated.

What the placement writes (#331)

Three events are written in the same transaction as the placement. If any of them fails, the placement rolls back with it (#316, #331, #361, Tasks.php):

Event Who can read it Contents
task.reassigned Every session Task #N reassigned., with meta {task_id, to: "claimed", assigned_to}, plus sub_label when the task had one before the placement
placement.instruction The lane, and the placing coordinator's own developer's sessions The coordinator's directive, verbatim, with meta {task_id, hand_back, to: [lane]}
directive Every session Text composed by the package: Task #N was placed on session #L[ as a hand-back]. Its instructions are event #I, a placement.instruction addressed to that session: read the feed from after=I-1 to see them. Its meta is {targets: [lane], task_id, hand_back, instruction_id}

The coordinator's own words never go in the fleet-wide directive, because they may name an issue that another developer's agent is not allowed to read (FleetEventType.php, FleetFeed.php). The instruction is written first so that the directive can name its id. A lane whose bridge has already read past the instruction can read it back with after set to one less than that id (Tasks.php); see The Event Feed for why a lane's cursor can already be past it. That read is also an acknowledgement: it acknowledges everything through instruction_id - 1, so any event before the instruction that you had not yet read is skipped.

What the lane does next

  1. Read the instruction. Call events_read with after set to instruction_id - 1 (Tasks.php). That read acknowledges everything through instruction_id - 1, so if you might be behind, read forward from your own last cursor instead until you reach the instruction.
  2. task_start. This moves the task from claimed to in_progress.
  3. Create your branch, then report it with task_branch or POST {prefix}/api/tasks/{task}/branch (needs tasks:claim). It works only while you hold the task and it is in_progress or blocked. A second report replaces the first. It takes branch, sub_label (below), or both, and leaves alone whichever it was not sent. It writes no event (#338, TaskBranchTool.php, Tasks.php). The reported branch is how a pull request is matched back to the task (below).
  4. Finish with task_complete or task_fail, or let GitHub finish the task.

Sub-labels (#409)

A lane that hands its tasks to subagents can label each one, so its held tasks can be told apart. A sub-label is sent on start or on the branch report, and it is 1 to 64 characters of [A-Za-z0-9._-] starting with a letter or digit. Anything else is refused with 422; an empty value counts as not sent (#409, SubLabel.php, ReportTaskBranchController.php, TaskBranchTool.php).

  • It is display only. Nothing reads it to decide anything. Claims, locks, and narration remain the session's, because subagents do not join.
  • Every session in the fleet can read it, in meta.sub_label on the task's events. It must never name an issue, a branch, or anything confidential. Use something like subagent-2 or a worktree slot name. Who sees it where is in Reach and Visibility.
  • Which events carry it. task.started, task.blocked, task.completed, task.failed, task.released, and task.cancelled carry the label the task had at that moment; a release carries the label it is clearing. task.reassigned carries the label from before the placement, so moving a labeled task to another lane records the previous lane's label. The sweep's task.released and GitHub's task.completed or task.released carry it too. task.claimed never does, because a claim starts from pending and every way to pending clears the label. An event about a task with no label has no sub_label key (Tasks.php).
  • When it is cleared. When the task changes hands (a claim, or a placement onto a different lane), and whenever the task returns to pending: a release, the sweep, or GitHub closing a pull request unmerged. A placement back onto the lane already holding the task keeps it.

A task in claimed after a placement does not mean the lane has seen it. A placement always lands as claimed. What shows the lane has taken the work up is its own start: the lane board reports taken_up only for in_progress, and marks the provenance placed and told for a coordinator placement and chosen by the lane for a claim (LaneBoard.php, TaskTransition.php).

Tasks finished by GitHub (#318)

When the GitHub webhook is configured at {prefix}/api/github/webhook, GitHub events can finish held tasks (#318, GitHubState.php, Tasks.php):

GitHub event Tasks affected Result
Issue closed Held tasks whose issue is that issue (compared without case) done
Pull request merged Held tasks whose reported branch equals the pull request's head branch exactly, and whose issue is in the same repository. A pull request from a fork matches no task. done
Pull request closed without merging Same match as a merge Released to pending
  • A deleted or transferred issue frees no task. Its work did not finish.
  • Only a held task moves. A redelivered close cannot finish a task twice.
  • The match is re-checked in the write. A task re-placed in the meantime is not finished by the previous lane's pull request.
  • The event is attributed to no session. It is task.completed or task.released, with meta {task_id, to, source: "github", released_from}, plus sub_label when the task had one. It names the reason and never the issue.

The task keeps what finished it, in result (since v0.7.1). A completion writes a github entry: the reason, plus either the pull request, merged and the merge commit's SHA, or the issue and GitHub's state_reason. A release writes nothing, because the task goes back with a clean slate. result is shown only to a session that may read the task (#433, Tasks.php, GitHubState.php).

The lane GitHub beat to it can still add its own result, once. A complete from the session that held the task, within 60 minutes of GitHub finishing it, is not refused: its result is merged into the recorded one and the status stays done. The answer is 200 with applied: false and result_added: true, and the feed records task.result_added rather than a second task.completed. GitHub's github entry wins over a github key the lane sends, and a result sent as a list is kept under reported. Any other session, a second addition, a call after the hour, or a complete with no result gets the usual 409.

A task with no reported branch cannot be matched to a pull request. Neither can a pull request opened from a fork, because the package records no head branch for one.

When a session goes

A held task whose session has gone, or whose holder no longer exists, is released to pending by the presence sweep. The sweep runs every minute while robot-council.schedule.sweep_sessions is on, which is the default. The release writes task.released (Task #N released: its session ended.), attributed to no session, with released_from in meta, plus sub_label when the task had one. Ending a session with DELETE {prefix}/api/sessions/{id} marks it gone, and its tasks are released on the next sweep (Tasks.php, SessionPresence.php, RobotCouncilServiceProvider.php).

Lane holds

A coordinator can record why an idle lane is held with lane_hold, and lift it with lane_clear_hold (POST and DELETE {prefix}/api/lanes/{session}/hold, both needing coordinator:direct). A lane that holds a task cannot be held. A claim or a reassign that gives a lane a task deletes that lane's hold in the same transaction (#334, LaneHolds.php, Tasks.php, routes/api.php).

Clone this wiki locally