feat(tasks): native task tracking — aggregate, agent tools, context bundle
Adds task tracking as a first-class event-sourced aggregate (ADR-0012). The
event log stays the only source of truth; the board is a projection, and all of
a project's task events live in one stream (tasks:<projectId>).
core:
- new core:tasks module mirroring core:sessions: TaskStatus/State/Reducer/
BoardProjector/CounterProjection/Repository + TaskService write path that
appends events via the existing EventStore (no new storage)
- task events in core:events (sealed marker TaskEvent), registered in
Serialization.kt; status is derived by the reducer from lifecycle facts
- work-graph edges: TaskLinkType + typed TaskTargetKind on the link, so bundle
resolution dispatches on the recorded kind instead of guessing from the id
- delete is a soft tombstone (TaskDeletedEvent); cancel stays a visible outcome
surfaces:
- agent tools (Tier T2 mutators, T1 read): task_create / task_update /
task_delete / task_context, registered in both the main and per-workspace
tool registries
- REST GET /tasks/{id}/context for external agents
context bundle (TaskContextAssembler):
- task fields + acceptance criteria + relevant files + resolved dependency
tasks (live status) + raw related links + notes, with a compact render()
- semantic enrichment via a TaskKnowledgeRetriever port bridged to the existing
L3 retriever (late-bound holder for composition-root ordering)
- ADR/doc resolution via a TaskDocumentResolver port + filesystem adapter
(docs/decisions/adr-*.md, padding-agnostic; *.md paths)
Unit-verified across core:tasks / infrastructure:tools / apps:server; full
gradlew check green. Not yet live-QA'd end-to-end against a running server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ dependencies {
|
||||
implementation project(':core:events')
|
||||
implementation project(':core:approvals')
|
||||
implementation project(':core:sessions')
|
||||
implementation project(':core:tasks')
|
||||
implementation project(':core:kernel')
|
||||
implementation project(':core:journal')
|
||||
implementation project(':core:inference')
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.correx.apps.server
|
||||
import com.correx.apps.server.health.HealthInspectionService
|
||||
import com.correx.apps.server.routes.providerRoutes
|
||||
import com.correx.apps.server.routes.sessionRoutes
|
||||
import com.correx.apps.server.routes.taskRoutes
|
||||
import com.correx.apps.server.routes.workflowRoutes
|
||||
import com.correx.apps.server.ws.GlobalStreamHandler
|
||||
import io.ktor.http.HttpStatusCode
|
||||
@@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) {
|
||||
sessionRoutes(module)
|
||||
workflowRoutes(module)
|
||||
providerRoutes(module)
|
||||
taskRoutes(module)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
||||
import com.correx.core.validation.artifact.ArtifactPayloadValidator
|
||||
@@ -80,6 +81,7 @@ import com.correx.infrastructure.inference.commons.UnavailableProbe
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
||||
import com.correx.infrastructure.tools.task.TaskTools
|
||||
import com.correx.infrastructure.tools.FileEditConfig
|
||||
import com.correx.infrastructure.tools.FileReadConfig
|
||||
import com.correx.infrastructure.tools.FileWriteConfig
|
||||
@@ -211,6 +213,16 @@ fun main() {
|
||||
null
|
||||
},
|
||||
)
|
||||
// Agents create/update/delete tasks through the tool system (tier-gated like any tool);
|
||||
// the service appends to the same event log. Project is derived from the task id prefix.
|
||||
// The knowledge retriever is late-bound: the L3 retriever isn't built until later in this
|
||||
// composition root, so the holder is captured now and its delegate set once L3 exists (below).
|
||||
val taskKnowledgeRetriever = com.correx.apps.server.memory.DeferredTaskKnowledgeRetriever()
|
||||
// Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the
|
||||
// L3 retriever this needs no late binding.
|
||||
val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot)
|
||||
val taskService = TaskService(eventStore)
|
||||
val taskTools = TaskTools.forService(taskService, taskKnowledgeRetriever, taskDocumentResolver)
|
||||
val toolRegistry = InfrastructureModule.createToolRegistry(
|
||||
buildToolConfig(
|
||||
workspaceRoot,
|
||||
@@ -219,6 +231,7 @@ fun main() {
|
||||
toolsConfig,
|
||||
researchToolConfig,
|
||||
),
|
||||
extraTools = taskTools,
|
||||
)
|
||||
val toolExecutor = InfrastructureModule.createToolExecutor(
|
||||
registry = toolRegistry,
|
||||
@@ -246,6 +259,7 @@ fun main() {
|
||||
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
|
||||
val wsRegistry = InfrastructureModule.createToolRegistry(
|
||||
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
|
||||
extraTools = taskTools,
|
||||
)
|
||||
val wsExecutor = DispatchingToolExecutor(wsRegistry)
|
||||
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
|
||||
@@ -311,6 +325,10 @@ fun main() {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// L3 retriever now exists — bind it into the task context bundle's knowledge port so the
|
||||
// task_context tool and GET /tasks/{id}/context return semantically-relevant snippets.
|
||||
taskKnowledgeRetriever.delegate =
|
||||
repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) }
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines,
|
||||
@@ -501,6 +519,8 @@ fun main() {
|
||||
operatorProfile = operatorProfile,
|
||||
profileAdaptationService = profileAdaptationService,
|
||||
healthMonitor = healthMonitor,
|
||||
taskKnowledgeRetriever = taskKnowledgeRetriever,
|
||||
taskDocumentResolver = taskDocumentResolver,
|
||||
)
|
||||
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
|
||||
// services. Built after the module so the rebuild hook can swap them in place.
|
||||
|
||||
@@ -115,6 +115,10 @@ class ServerModule(
|
||||
// Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false);
|
||||
// records degraded/restored edge events on the system session, started in [start].
|
||||
private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null,
|
||||
// Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it.
|
||||
val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null,
|
||||
// Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links.
|
||||
val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null,
|
||||
) {
|
||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||
orchestrator = orchestrator,
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.correx.apps.server.memory
|
||||
|
||||
import com.correx.core.tasks.KnowledgeHit
|
||||
import com.correx.core.tasks.TaskKnowledgeRetriever
|
||||
|
||||
/**
|
||||
* Late-bound holder for the task knowledge retriever. The task tools are built at tool-registry
|
||||
* construction time, which is before the L3 retriever exists in the composition root; this holder
|
||||
* is created early, captured by the tools and the route, and has its [delegate] set once the L3
|
||||
* retriever is available. Until then (or when L3 is disabled) it returns no hits.
|
||||
*/
|
||||
class DeferredTaskKnowledgeRetriever : TaskKnowledgeRetriever {
|
||||
@Volatile
|
||||
var delegate: TaskKnowledgeRetriever? = null
|
||||
|
||||
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
|
||||
delegate?.retrieve(query, limit) ?: emptyList()
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.correx.apps.server.memory
|
||||
|
||||
import com.correx.apps.server.health.SYSTEM_SESSION
|
||||
import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever
|
||||
import com.correx.core.tasks.KnowledgeHit
|
||||
import com.correx.core.tasks.TaskKnowledgeRetriever
|
||||
|
||||
/**
|
||||
* Bridges the task context bundle's [TaskKnowledgeRetriever] port to the existing L3 vector
|
||||
* retriever ([RepoKnowledgeRetriever], typically [L3RepoKnowledgeRetriever]). Tasks are not
|
||||
* session-scoped for retrieval, so the system session is used (the L3 repo retriever ignores it).
|
||||
*/
|
||||
class RepoKnowledgeTaskRetriever(
|
||||
private val delegate: RepoKnowledgeRetriever,
|
||||
) : TaskKnowledgeRetriever {
|
||||
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
|
||||
delegate.retrieve(SYSTEM_SESSION, query, limit)
|
||||
.map { KnowledgeHit(source = it.path, text = it.text, score = it.score.toDouble()) }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.correx.apps.server.routes
|
||||
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.core.tasks.TaskContextAssembler
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
|
||||
/**
|
||||
* Read surface for tasks. Exposes the agent context bundle (`GET /tasks/{id}/context`) for
|
||||
* external agents (Claude Code / aider / codex). In-process roles use the `task_context` tool,
|
||||
* which shares the same [TaskContextAssembler]. Both read from the event log; nothing is stored.
|
||||
*/
|
||||
fun Route.taskRoutes(module: ServerModule) {
|
||||
val assembler = TaskContextAssembler(
|
||||
TaskService(module.eventStore),
|
||||
module.taskKnowledgeRetriever,
|
||||
module.taskDocumentResolver,
|
||||
)
|
||||
route("/tasks/{id}") {
|
||||
get("/context") {
|
||||
val id = call.parameters["id"]
|
||||
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
|
||||
val bundle = assembler.assemble(TypeId(id))
|
||||
?: return@get call.respond(HttpStatusCode.NotFound, "Task not found")
|
||||
call.respond(bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.correx.apps.server.tasks
|
||||
|
||||
import com.correx.core.tasks.ResolvedDocument
|
||||
import com.correx.core.tasks.TaskDocumentResolver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.isDirectory
|
||||
import kotlin.io.path.isRegularFile
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.readText
|
||||
|
||||
private const val EXCERPT_MAX = 280
|
||||
private val ADR_ID = Regex("(?i)^adr[-_ ]?0*(\\d+)$")
|
||||
private val ADR_FILE = Regex("^adr-0*(\\d+)")
|
||||
private val FRONTMATTER_NAME = Regex("(?m)^name:\\s*\"?(.+?)\"?\\s*$")
|
||||
private val FRONTMATTER_DESC = Regex("(?m)^description:\\s*\"?(.+?)\"?\\s*$")
|
||||
|
||||
/**
|
||||
* Resolves link targets to repo documents for the task context bundle:
|
||||
* - ADR ids ("adr-7", "ADR-0007", "adr 12") → `docs/decisions/adr-<n>-*.md` (matched by number,
|
||||
* padding-agnostic);
|
||||
* - explicit "*.md" paths under the repo root.
|
||||
* Title is the frontmatter `name:` or first `# ` heading; excerpt is the frontmatter
|
||||
* `description:` or the first prose paragraph, capped. Returns null for anything else.
|
||||
*/
|
||||
class FileTaskDocumentResolver(
|
||||
private val repoRoot: Path,
|
||||
private val decisionsDir: Path = repoRoot.resolve("docs/decisions"),
|
||||
) : TaskDocumentResolver {
|
||||
|
||||
override suspend fun resolve(targetId: String): ResolvedDocument? = withContext(Dispatchers.IO) {
|
||||
resolveAdr(targetId.trim()) ?: resolveMarkdownPath(targetId.trim())
|
||||
}
|
||||
|
||||
private fun resolveAdr(targetId: String): ResolvedDocument? {
|
||||
val num = ADR_ID.matchEntire(targetId)?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
||||
if (!decisionsDir.isDirectory()) return null
|
||||
val file = Files.list(decisionsDir).use { stream ->
|
||||
stream.filter { it.isRegularFile() && it.name.endsWith(".md") }
|
||||
.filter { adrNumber(it.name) == num }
|
||||
.findFirst()
|
||||
.orElse(null)
|
||||
} ?: return null
|
||||
return readDocument(file)
|
||||
}
|
||||
|
||||
private fun resolveMarkdownPath(targetId: String): ResolvedDocument? {
|
||||
if (!targetId.endsWith(".md")) return null
|
||||
val candidate = repoRoot.resolve(targetId).normalize()
|
||||
if (!candidate.startsWith(repoRoot) || !candidate.exists() || !candidate.isRegularFile()) return null
|
||||
return readDocument(candidate)
|
||||
}
|
||||
|
||||
private fun adrNumber(fileName: String): Int? =
|
||||
ADR_FILE.find(fileName.lowercase())?.groupValues?.get(1)?.toIntOrNull()
|
||||
|
||||
private fun readDocument(file: Path): ResolvedDocument {
|
||||
val text = runCatching { file.readText() }.getOrDefault("")
|
||||
return ResolvedDocument(
|
||||
title = extractTitle(text) ?: file.name,
|
||||
path = repoRoot.relativize(file).toString(),
|
||||
excerpt = extractExcerpt(text),
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractTitle(text: String): String? {
|
||||
FRONTMATTER_NAME.find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return text.lineSequence().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim()
|
||||
}
|
||||
|
||||
private fun extractExcerpt(text: String): String {
|
||||
val desc = FRONTMATTER_DESC.find(text)?.groupValues?.get(1)?.trim()
|
||||
return (desc ?: firstParagraph(text)).take(EXCERPT_MAX).trim()
|
||||
}
|
||||
|
||||
private fun firstParagraph(text: String): String =
|
||||
stripFrontmatter(text).lineSequence()
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") && !it.startsWith("---") }
|
||||
?: ""
|
||||
|
||||
private fun stripFrontmatter(text: String): String {
|
||||
if (!text.startsWith("---")) return text
|
||||
val lines = text.lines()
|
||||
val closeIdx = lines.drop(1).indexOfFirst { it.trim() == "---" }
|
||||
return if (closeIdx >= 0) lines.drop(closeIdx + 2).joinToString("\n") else text
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.apps.server.tasks
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
class FileTaskDocumentResolverTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var repo: Path
|
||||
|
||||
private fun writeAdr() {
|
||||
val dir = repo.resolve("docs/decisions").also { it.createDirectories() }
|
||||
dir.resolve("adr-0007-redis.md").writeText(
|
||||
"""
|
||||
---
|
||||
name: "ADR 7 Redis Sessions"
|
||||
description: "use redis for session storage"
|
||||
---
|
||||
|
||||
# ADR 7: use redis for session storage
|
||||
|
||||
**status:** accepted
|
||||
|
||||
## decision
|
||||
Redis backs session storage.
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolves an ADR id padding-agnostically`() = runBlocking {
|
||||
writeAdr()
|
||||
val resolver = FileTaskDocumentResolver(repo)
|
||||
|
||||
val byShort = resolver.resolve("adr-7")
|
||||
val byPadded = resolver.resolve("ADR-0007")
|
||||
|
||||
assertEquals("ADR 7 Redis Sessions", byShort?.title)
|
||||
assertEquals("use redis for session storage", byShort?.excerpt)
|
||||
assertEquals("docs/decisions/adr-0007-redis.md", byShort?.path)
|
||||
assertEquals(byShort, byPadded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown ADR resolves to null`() = runBlocking {
|
||||
writeAdr()
|
||||
assertNull(FileTaskDocumentResolver(repo).resolve("adr-999"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolves an explicit markdown path and falls back to heading and first paragraph`() = runBlocking {
|
||||
repo.resolve("docs/notes").createDirectories()
|
||||
repo.resolve("docs/notes/plan.md").writeText("# Plan\n\nThe plan body.\n")
|
||||
|
||||
val doc = FileTaskDocumentResolver(repo).resolve("docs/notes/plan.md")
|
||||
|
||||
assertEquals("Plan", doc?.title)
|
||||
assertEquals("The plan body.", doc?.excerpt)
|
||||
assertEquals("docs/notes/plan.md", doc?.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-document target resolves to null`() = runBlocking {
|
||||
assertNull(FileTaskDocumentResolver(repo).resolve("ext-123"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user