Task dependencies and ready work

Versions

#346 • Updated Sep 23, 2026

Specification for #76 (from #92). It decides what "ready" means, how blockers behave and what a claim checks. Blockers (#93) and the ready queue with strict claims (#94) are built; the web controls (#95) and agent onboarding (#96) follow. It builds on the relation model in #287 and the task rules in TaskService.

Ready work is a recommendation and an eligibility check, never a scheduler: nothing starts, reassigns or changes status on its own.

Concepts

Concept Meaning Affects readiness
Parent / subtask Decomposition. Siblings are unordered Yes: a parent with open subtasks is a container, not work; blockers and a manual block of an ancestor apply to its descendants
blocked_by The source task cannot start before the target task is done Yes
Manual blocked status Waiting on something outside the graph (a person, a decision, an external system), with a reason Yes
related, references, implements Information only (#287) No
discovered_from The source task was found while working on the target item (task or memory) No

A blocked_by edge never changes a status. The status field keeps its five values (open, in_progress, blocked, done, canceled); "waiting on #10" is derived from the graph and shown next to the status, it is not stored.

Blockers

  • Stored as a ProjectItemRelation of kind blocked_by, source = the waiting task, target = the prerequisite. Read in both directions: #11 shows "Waits for #10", #10 shows "Blocks #11".
  • Both endpoints are tasks of the same project. Memories, pages and other projects are rejected ("Blockers link tasks of one project"). Cross-project blockers are deferred.
  • Created with item_action relate (relation: blocked_by) and removed with unrelate, like other explicit relations: no item version is needed and neither task's version changes. Each change is recorded in the waiting task's history, so a removed blocker is auditable.
  • Adding one needs write access to the project. It needs no access beyond that: both tasks are in the same project.
  • Rejected: a self-link; an edge that closes a cycle (direct or transitive); an edge between a task and its own ancestor or descendant. A subtask waiting for its parent would deadlock, because a parent cannot close before its subtasks; a parent waiting for its subtask is already implied. Adding an existing edge or removing a missing one succeeds and changes nothing.
  • An edge may point at a closed prerequisite (it is satisfied at once when that one is done) but not start from a closed task.

When a blocker is satisfied

Prerequisite Satisfied What the waiting task explains
done Yes nothing
open, in_progress, blocked No "Waits for #10 (in progress)"
canceled No "#10 was canceled: remove this blocker or replace it"
archived and not done No "#10 was archived before it was done: remove this blocker or replace it"
privacy-restricted or otherwise hidden No "Waits for a task you cannot see" (never its number or title)
deleted the edge is deleted with it nothing

A canceled prerequisite is never silently counted as success: someone decides to remove the blocker, replace it with another task, or reopen the prerequisite.

Reopening a done prerequisite makes its waiting tasks unready again. A waiting task that is already in_progress keeps its status and owner.

Ready

A task is ready for a caller when all of these hold:

  1. Its status is open.
  2. It is unassigned, or assigned to the caller.
  3. Every blocked_by prerequisite of the task and of each ancestor is satisfied.
  4. No ancestor has the manual status blocked.
  5. It has no active subtasks (a parent whose subtasks are all closed is ready again, for example for a wrap-up).
  6. It is neither archived nor privacy-restricted.

in_progress work is never "ready". It is reported separately as the caller's work in progress (assigned to the caller) and as taken (assigned to others).

Order: priority (high, normal, low), then oldest first (FIFO by creation), then number. A task assigned to the caller does not jump the queue.

Truth table

"Blockers" covers the task's own blockers and those of its ancestors; "leaf" means no active subtasks. The state column is what TaskService.Readiness returns.

Status Blockers Ancestor manually blocked Assignee Leaf Ready for caller State
open satisfied no none yes yes ready
open satisfied no caller yes yes ready
open any any someone else any no taken
open unsatisfied any none or caller any no waiting
open satisfied yes none or caller any no waiting ("Parent #P is blocked")
open satisfied no none or caller no no container
in_progress any any caller any no in_progress (yours)
in_progress any any someone else any no taken
blocked any any any any no blocked
done / canceled any any any any no closed

Claim

A claim is the eligibility check. In one transaction, under the project's task lock, it:

  1. rejects a closed task (existing);
  2. succeeds without a change when the task is already the caller's and in_progress, whatever version was sent, so a retry after a lost answer is harmless;
  3. rejects a changed version (existing);
  4. rejects a task assigned to someone else: "#12 is already taken by Codex" (new: before, a claim silently took a task over);
  5. rejects a manually blocked task: unblock it first;
  6. rejects a task that waits for its own or an ancestor's prerequisite, with the same reasons item_get shows;
  7. assigns the caller and sets in_progress.

A parent with open subtasks may be claimed (decided while building #94): claiming an epic is how a person or agent takes ownership of a whole piece of work. Containers are only left out of the ready queue.

There is no override flag. Starting blocked work anyway means removing the blocker or unblocking first, which is explicit and recorded. Reassigning someone else's task stays an explicit assignee change (item_store), which already needs write access and appears in history. No claim, completion or blocker change ever starts, assigns or changes the status of another task.

Concurrency

Blocker mutations and claims take the same project-scoped lock (RelationService.LockTasks: sp_getapplock on tasks:{projectId} in SQL Server; SQLite serialises writers anyway). The cycle check, the ancestry check and the readiness check read their data under that lock, so:

  • two people or agents claiming the same task: one succeeds, the other gets "already taken" (ClaimConcurrencyTests on SQL Server);
  • A adds #11 → #10 while B adds #10 → #11: the second sees the first edge and is rejected as a cycle (BlockerConcurrencyTests);
  • a blocker added while a claim runs: the claim either sees it and fails, or completes first; the blocker then applies to an already started task, which keeps its status and shows "waiting".

Surfaces

  • item_list(kind: task, filter: {ready: true}) returns the caller's ready queue in ready order (a sort overrides the order). Every task entry carries state when it is ready, waiting or container; taken, in-progress, blocked and closed tasks are already told apart by status and assignee.
  • item_get on a task returns readiness: {state, reasons[]} for active tasks, and the blocked_by relations in both directions.
  • item_action relate/unrelate accepts blocked_by and discovered_from. The tool description does not list them yet (tool list budget, #347); the system guide (v34) does.
  • recall lists the caller's work in progress first, then ready tasks, then the rest, each line tagged yours, ready, container or with what it waits for.
  • Web: the task heading shows every blocker link (Waits for #N yellow, #N canceled red, Waited for #N ✓ green, Blocks #N, #M), task list rows show Waits for #N. Still to come in #95: a Ready filter on the task list and dedicated add/remove controls.

Worked examples

Migration blocks deployment. #10 "Add migration", #11 "Deploy" with #11 blocked_by #10. While #10 is open, the queue holds #10 only and #11 shows "Waits for #10". Codex claims #10 and completes it: #11 becomes ready. Nothing starts #11; the next agent that asks for ready work sees it. If #10 is canceled, #11 shows "#10 was canceled: remove this blocker or replace it" and stays out of the queue.

Siblings stay unordered. Epic #20 has subtasks #21, #22, #23. All three are ready at once and can be claimed by three agents in parallel; #20 is a container until they close, and can still be claimed by whoever owns the epic. If #22 really needs #21 first, that is stated with #22 blocked_by #21, never inferred from position or number.

A follow-up discovered from a bug. While fixing #30, Claude finds an unrelated flaw and creates #31 with discovered_from #30. #31 is ready at once; #30 is unaffected. If the fix of #30 in fact needs #31 first, Claude adds #30 blocked_by #31: #30 stays in_progress and owned by Claude, and shows "waiting on #31".

A blocked epic. #40 "Payment rework" is blocked_by #10. Its subtasks #41 and #42 are not ready until #10 is done, and explain "Parent #40 waits for #10 (open)". Setting #40 to the manual status blocked ("waiting for legal review") likewise holds back #41 and #42.

Two agents, one task. Codex and Claude both read #50 at version 3 and claim it. Codex commits first; Claude's claim fails with "#50 is already taken by Codex" and asks for the ready queue again.

A cycle. #11 blocked_by #10 exists. Adding #10 blocked_by #11 is rejected: "#11 already waits for #10 (#11 → #10); blockers cannot form a cycle". So is adding #10 blocked_by #12 when #12 blocked_by #11.

Out of scope for v1

  • Blockers across projects and between tasks and memories or pages.
  • Soft or optional dependencies, due dates, estimates.
  • Automatic starting, scheduling or assignment of ready work.
  • Automatic unblocking of manually blocked tasks when a related task closes.
Connection interrupted or session ended. Reload

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.