-
Notifications
You must be signed in to change notification settings - Fork 0
The Task Lifecycle
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.
| 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).
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
releasea task it does not hold. It cannotstart,block,completeorfailone on another session's behalf.reassignandcancelare the coordinator's own transitions and need no hold (TaskTransition.php). -
starttakes an optionalbranch. Omitting it leaves any recorded branch alone, so resuming a blocked task keeps its branch (Tasks.php). -
startalso takes an optionalsub_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). -
completeandfailtake an optionalresultobject, bounded in size. No other transition records one (TaskTransition.php,TransitionTaskController.php). -
Anything that returns a task to
pendingwipes 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>.Itsmetaholdstask_idandto, plusassigned_toonclaimandreassign, andsub_labelwhen the task had one (Tasks.php, #409). Which label each event carries is under Sub-labels.
| 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).
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:directat 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).
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).
| 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). -
expectis checked in the write itself. If a lane claimed the task after you read it aspending, the placement answers 409 and takes nothing from that lane. Onlyreassignacceptsexpect. 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
gonebetween validation and the write gets a 409 (Tasks.php). -
The task records how it came to be held. A placement stores
placed_by: coordinatorand thehand_backflag, 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 ownclaimstoresplaced_by: lane(Tasks.php).
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/sessionstakes an optionalcapacity. 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/sessionboth returncapacity(in effect) anddeclared_capacity(asked for, within 16).sessions_listandGET {prefix}/api/lanesreturncapacityfor each session, and thesession.joinedevent carriesmeta.declared_capacity(AgentSessionController.php,LiveSessions.php). The lane board shows each lane's occupancy as held over capacity, such as2 / 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
claimis not limited by capacity, as before. -
lane_freekeeps its name, its refusal text, and its waivers. At a capacity of 1 the rule is exactly the one it replaced (PlacementRule.php).
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, andticket_unblocked. -
A coordinator reads the hours before placing, with
developer_settings(since v0.7.1). The tool needscoordinator:directand returns whatGET {prefix}/api/developers/settingsreturns: each developer's hours and days off, and each seat's parked, exempt andmax_capacityvalues. Each developer and seat also carriesinside_hours(true,false, or"ungated"when no hours apply) andnext_opens_atwhen it isfalse, 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 apendingtask 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 owntask.startedevent. 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
documentationwhile 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.
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.
-
Read the instruction. Call
events_readwithafterset toinstruction_id - 1(Tasks.php). That read acknowledges everything throughinstruction_id - 1, so if you might be behind, read forward from your own last cursor instead until you reach the instruction. -
task_start. This moves the task fromclaimedtoin_progress. -
Create your branch, then report it with
task_branchorPOST {prefix}/api/tasks/{task}/branch(needstasks:claim). It works only while you hold the task and it isin_progressorblocked. A second report replaces the first. It takesbranch,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). -
Finish with
task_completeortask_fail, or let GitHub finish the task.
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_labelon the task's events. It must never name an issue, a branch, or anything confidential. Use something likesubagent-2or 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, andtask.cancelledcarry the label the task had at that moment; a release carries the label it is clearing.task.reassignedcarries the label from before the placement, so moving a labeled task to another lane records the previous lane's label. The sweep'stask.releasedand GitHub'stask.completedortask.releasedcarry it too.task.claimednever does, because a claim starts frompendingand every way topendingclears the label. An event about a task with no label has nosub_labelkey (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).
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.completedortask.released, withmeta{task_id, to, source: "github", released_from}, plussub_labelwhen 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.
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).
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).