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:
2026-06-23 19:00:58 +00:00
parent 7909357747
commit 0c6ac903b7
51 changed files with 2431 additions and 2 deletions
+1
View File
@@ -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,
@@ -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()
}
@@ -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
}
}
@@ -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"))
}
}