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:
@@ -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")
|
||||
|
||||
@@ -126,6 +126,12 @@ fun Route.taskRoutes(module: ServerModule) {
|
||||
|
||||
private fun Route.taskCollectionRoutes(service: TaskService) {
|
||||
get {
|
||||
// ?ready=true short-circuits to the dependency-aware workable set (TODO, no unmet blockers).
|
||||
if (call.request.queryParameters["ready"]?.toBoolean() == true) {
|
||||
val readyProject = call.request.queryParameters["project"]
|
||||
val workable = service.ready(readyProject?.let { ProjectId(it) })
|
||||
return@get call.respond(workable.map { it.toResponse() })
|
||||
}
|
||||
val statusRaw = call.request.queryParameters["status"]
|
||||
val statusFilter = statusRaw?.let {
|
||||
parseEnum<TaskStatus>(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it")
|
||||
@@ -209,6 +215,12 @@ private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAss
|
||||
if (events.isEmpty()) return@get call.respond(HttpStatusCode.NotFound, "Task not found")
|
||||
call.respondText(TaskHistory.render(events), ContentType.Text.Plain)
|
||||
}
|
||||
// Unfinished tasks this one is waiting on (its dependencies + inbound blocks).
|
||||
get("/blockers") {
|
||||
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
|
||||
if (service.getTask(id) == null) return@get call.respond(HttpStatusCode.NotFound, "Task not found")
|
||||
call.respond(service.blockers(id).map { it.toResponse() })
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.taskTransitionRoutes(service: TaskService) {
|
||||
|
||||
@@ -256,6 +256,35 @@ class TaskRoutesTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ready returns unblocked TODO tasks and blockers explains a wait`(@TempDir tempDir: Path) {
|
||||
testApplication {
|
||||
application { configureServer(buildModule(tempDir)) }
|
||||
val client = createClient {}
|
||||
client.createTask() // demo-1, TODO
|
||||
client.post("/tasks") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"project":"demo","title":"second","goal":"g"}""")
|
||||
}
|
||||
// demo-2 depends on demo-1 (id infers to a TASK target).
|
||||
client.post("/tasks/demo-2/links") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""")
|
||||
}
|
||||
|
||||
val ready = client.get("/tasks?ready=true")
|
||||
assertEquals(HttpStatusCode.OK, ready.status)
|
||||
val rows = testJson.parseToJsonElement(ready.bodyAsText()) as JsonArray
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
|
||||
|
||||
val blockers = client.get("/tasks/demo-2/blockers")
|
||||
assertEquals(HttpStatusCode.OK, blockers.status)
|
||||
val blk = testJson.parseToJsonElement(blockers.bodyAsText()) as JsonArray
|
||||
assertEquals("demo-1", (blk.single() as JsonObject)["id"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GET history renders the lifecycle timeline`(@TempDir tempDir: Path) {
|
||||
testApplication {
|
||||
|
||||
Reference in New Issue
Block a user