feat(tasks): dependency-aware work graph (blockers, ready, blocking)

The DEPENDS_ON/BLOCKS link types were inert metadata — nothing reasoned about
them. TaskGraph turns them into answerable questions: what a task waits on
(its DEPENDS_ON targets plus any task that BLOCKS it), whether it is ready
(TODO with no unmet blocker — a terminal dependency stops blocking), and what
finishing it would unblock. Readiness surfaces workable tasks to claim; it
never assigns (honouring the rejected /tasks/next scheduler).

Surfaced across the stack:
- TaskService.ready / blockers / blocking.
- Context bundle: a `blocked` flag + `blocked_by` list, rendered as a prominent
  "BLOCKED — waiting on ..." line, so an agent calling task_context learns it
  should wait before charging at the task.
- GET /tasks?ready=true, GET /tasks/{id}/blockers; correx task ready / blockers.

Cross-project dependencies are scoped to a project for now (documented in
TaskGraph). Tests cover both link directions, terminal-dependency clearing,
the ready filter, the reverse blocking direction, the bundle flag, and REST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:02:03 +00:00
parent bce62c080c
commit e77b2960f9
10 changed files with 276 additions and 0 deletions
@@ -89,8 +89,10 @@ class TaskCommand : CliktCommand(name = "task") {
init {
subcommands(
TaskListCommand(),
TaskReadyCommand(),
TaskSearchCommand(),
TaskShowCommand(),
TaskBlockersCommand(),
TaskHistoryCommand(),
TaskCreateCommand(),
TaskClaimCommand(),
@@ -133,6 +135,30 @@ class TaskListCommand : TaskHttpCommand("list") {
}
}
class TaskReadyCommand : TaskHttpCommand("ready") {
private val project by option("--project", help = "Limit to a project")
override fun run() = http { client ->
val query = project?.let { "&project=${enc(it)}" }.orEmpty()
val raw = client.get("${baseUrl()}/tasks?ready=true$query").bodyAsText()
if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw)))
}
}
class TaskBlockersCommand : TaskHttpCommand("blockers") {
private val id by argument("TASK_ID")
override fun run() = http { client ->
val resp = client.get("${baseUrl()}/tasks/$id/blockers")
if (resp.status == HttpStatusCode.NotFound) {
System.err.println("No such task: $id")
} else {
val raw = resp.bodyAsText()
if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw)))
}
}
}
class TaskSearchCommand : TaskHttpCommand("search") {
private val query by argument("QUERY", help = "Search text (quote multi-word queries)")
private val project by option("--project", help = "Limit to a project")