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:events')
implementation project(':core:approvals') implementation project(':core:approvals')
implementation project(':core:sessions') implementation project(':core:sessions')
implementation project(':core:tasks')
implementation project(':core:kernel') implementation project(':core:kernel')
implementation project(':core:journal') implementation project(':core:journal')
implementation project(':core:inference') 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.health.HealthInspectionService
import com.correx.apps.server.routes.providerRoutes import com.correx.apps.server.routes.providerRoutes
import com.correx.apps.server.routes.sessionRoutes 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.routes.workflowRoutes
import com.correx.apps.server.ws.GlobalStreamHandler import com.correx.apps.server.ws.GlobalStreamHandler
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) {
sessionRoutes(module) sessionRoutes(module)
workflowRoutes(module) workflowRoutes(module)
providerRoutes(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.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer 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.tools.registry.ToolRegistry
import com.correx.core.transitions.resolution.DefaultTransitionResolver import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.artifact.ArtifactPayloadValidator 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.core.inference.InferenceProvider
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import com.correx.infrastructure.tools.DispatchingToolExecutor import com.correx.infrastructure.tools.DispatchingToolExecutor
import com.correx.infrastructure.tools.task.TaskTools
import com.correx.infrastructure.tools.FileEditConfig import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig import com.correx.infrastructure.tools.FileWriteConfig
@@ -211,6 +213,16 @@ fun main() {
null 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( val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig( buildToolConfig(
workspaceRoot, workspaceRoot,
@@ -219,6 +231,7 @@ fun main() {
toolsConfig, toolsConfig,
researchToolConfig, researchToolConfig,
), ),
extraTools = taskTools,
) )
val toolExecutor = InfrastructureModule.createToolExecutor( val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry, registry = toolRegistry,
@@ -246,6 +259,7 @@ fun main() {
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace -> val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
val wsRegistry = InfrastructureModule.createToolRegistry( val wsRegistry = InfrastructureModule.createToolRegistry(
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig), buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
extraTools = taskTools,
) )
val wsExecutor = DispatchingToolExecutor(wsRegistry) val wsExecutor = DispatchingToolExecutor(wsRegistry)
WorkspaceTools(registry = wsRegistry, executor = wsExecutor) WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
@@ -311,6 +325,10 @@ fun main() {
} else { } else {
null 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( val orchestrator = DefaultSessionOrchestrator(
repositories = repositories, repositories = repositories,
engines = engines, engines = engines,
@@ -501,6 +519,8 @@ fun main() {
operatorProfile = operatorProfile, operatorProfile = operatorProfile,
profileAdaptationService = profileAdaptationService, profileAdaptationService = profileAdaptationService,
healthMonitor = healthMonitor, healthMonitor = healthMonitor,
taskKnowledgeRetriever = taskKnowledgeRetriever,
taskDocumentResolver = taskDocumentResolver,
) )
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived // 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. // 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); // 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]. // records degraded/restored edge events on the system session, started in [start].
private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null, 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( val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator, 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"))
}
}
@@ -0,0 +1,142 @@
package com.correx.core.events.events
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Marker over every task-lifecycle event. Deliberately *not* part of the serialized
* [EventPayload] hierarchy — concrete events implement both interfaces — so projections
* can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a
* giant `when`. Status is never carried on the wire: it is derived by the task reducer
* from these facts (mirroring how session status is derived from stage events).
*/
sealed interface TaskEvent {
val taskId: TaskId
}
@Serializable
@SerialName("TaskCreated")
data class TaskCreatedEvent(
override val taskId: TaskId,
val projectId: ProjectId,
val key: String,
val title: String,
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskClaimed")
data class TaskClaimedEvent(
override val taskId: TaskId,
val claimant: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReleased")
data class TaskReleasedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskBlocked")
data class TaskBlockedEvent(
override val taskId: TaskId,
val reason: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnblocked")
data class TaskUnblockedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskSubmittedForReview")
data class TaskSubmittedForReviewEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCompleted")
data class TaskCompletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReopened")
data class TaskReopenedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCancelled")
data class TaskCancelledEvent(
override val taskId: TaskId,
val reason: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskEdited")
data class TaskEditedEvent(
override val taskId: TaskId,
val title: String? = null,
val goal: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAcceptanceCriteriaSet")
data class TaskAcceptanceCriteriaSetEvent(
override val taskId: TaskId,
val criteria: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAffectedPathsSet")
data class TaskAffectedPathsSetEvent(
override val taskId: TaskId,
val paths: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskLinked")
data class TaskLinkedEvent(
override val taskId: TaskId,
val targetId: String,
// `linkType`, not `type`: the JSON polymorphic class discriminator is "type".
@SerialName("linkType") val type: TaskLinkType,
val targetKind: TaskTargetKind,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnlinked")
data class TaskUnlinkedEvent(
override val taskId: TaskId,
val targetId: String,
@SerialName("linkType") val type: TaskLinkType,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskNoteAdded")
data class TaskNoteAddedEvent(
override val taskId: TaskId,
val author: TaskNoteAuthor,
val body: String,
) : EventPayload, TaskEvent
/**
* Soft-delete tombstone. The event stays in the log (history is never rewritten); the board
* projection drops the task from active views. Distinct from cancellation, which is a
* lifecycle outcome that stays visible as CANCELLED.
*/
@Serializable
@SerialName("TaskDeleted")
data class TaskDeletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@@ -72,6 +72,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskDeletedEvent
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.polymorphic
@@ -151,6 +167,22 @@ val eventModule = SerializersModule {
subclass(LowQualityExtractionEvent::class) subclass(LowQualityExtractionEvent::class)
subclass(EgressHostsGrantedEvent::class) subclass(EgressHostsGrantedEvent::class)
subclass(StaticFindingsRecordedEvent::class) subclass(StaticFindingsRecordedEvent::class)
subclass(TaskCreatedEvent::class)
subclass(TaskClaimedEvent::class)
subclass(TaskReleasedEvent::class)
subclass(TaskBlockedEvent::class)
subclass(TaskUnblockedEvent::class)
subclass(TaskSubmittedForReviewEvent::class)
subclass(TaskCompletedEvent::class)
subclass(TaskReopenedEvent::class)
subclass(TaskCancelledEvent::class)
subclass(TaskEditedEvent::class)
subclass(TaskAcceptanceCriteriaSetEvent::class)
subclass(TaskAffectedPathsSetEvent::class)
subclass(TaskLinkedEvent::class)
subclass(TaskUnlinkedEvent::class)
subclass(TaskNoteAddedEvent::class)
subclass(TaskDeletedEvent::class)
} }
} }
@@ -19,6 +19,10 @@ typealias TransitionId = TypeId
typealias ArtifactId = TypeId typealias ArtifactId = TypeId
// Tasks types
typealias TaskId = TypeId
// Context types // Context types
typealias ContextPackId = TypeId typealias ContextPackId = TypeId
typealias ContextEntryId = TypeId typealias ContextEntryId = TypeId
@@ -0,0 +1,18 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]),
* or sessions (by [SessionId]) — the target is stored as a plain id string plus a type.
*/
@Serializable
enum class TaskLinkType {
@SerialName("DEPENDS_ON") DEPENDS_ON,
@SerialName("BLOCKS") BLOCKS,
@SerialName("IMPLEMENTS") IMPLEMENTS,
@SerialName("RELATES_TO") RELATES_TO,
@SerialName("PRODUCED") PRODUCED,
@SerialName("CONTEXT") CONTEXT,
}
@@ -0,0 +1,11 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Who authored a task note — used to keep the agent and human lanes separable. */
@Serializable
enum class TaskNoteAuthor {
@SerialName("AGENT") AGENT,
@SerialName("HUMAN") HUMAN,
}
@@ -0,0 +1,16 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* What a task link points at — recorded on the link so the context bundle dispatches resolution
* deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id.
*/
@Serializable
enum class TaskTargetKind {
@SerialName("TASK") TASK,
@SerialName("DOC") DOC,
@SerialName("ARTIFACT") ARTIFACT,
@SerialName("SESSION") SESSION,
}
+12
View File
@@ -0,0 +1,12 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation(project(":core:events"))
testImplementation(testFixtures(project(":testing:contracts")))
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,147 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import kotlinx.datetime.Instant
/**
* Folds task events into [TaskState]. Field-update events always apply; lifecycle events
* apply only on a legal transition — an illegal transition is ignored and counted in
* [TaskState.invalidTransitions] (mirrors how `SessionState` tracks invalid transitions).
*/
class DefaultTaskReducer : TaskReducer {
override fun reduce(state: TaskState, event: StoredEvent): TaskState {
val payload = event.payload as? TaskEvent ?: return state
val ts = event.metadata.timestamp
val base = state.copy(
createdAt = state.createdAt ?: ts,
updatedAt = ts,
)
return reduceContent(state, base, payload, ts)
?: reduceTransition(state, base, payload)
}
/** Field updates that do not touch status. Returns null for lifecycle events. */
private fun reduceContent(
state: TaskState,
base: TaskState,
payload: TaskEvent,
ts: Instant,
): TaskState? = when (payload) {
is TaskCreatedEvent -> base.copy(
status = TaskStatus.TODO,
key = payload.key,
projectId = payload.projectId,
title = payload.title,
goal = payload.goal,
acceptanceCriteria = payload.acceptanceCriteria,
affectedPaths = payload.affectedPaths,
)
is TaskEditedEvent -> base.copy(
title = payload.title ?: state.title,
goal = payload.goal ?: state.goal,
)
is TaskAcceptanceCriteriaSetEvent -> base.copy(acceptanceCriteria = payload.criteria)
is TaskAffectedPathsSetEvent -> base.copy(affectedPaths = payload.paths)
is TaskLinkedEvent -> base.copy(
links = (state.links + TaskLink(payload.targetId, payload.type, payload.targetKind)).distinct(),
)
is TaskUnlinkedEvent -> base.copy(
links = state.links.filterNot {
it.targetId == payload.targetId && it.type == payload.type
},
)
is TaskNoteAddedEvent -> base.copy(
notes = state.notes + TaskNote(payload.author, payload.body, ts),
)
is TaskDeletedEvent -> base.copy(deleted = true)
else -> null
}
/** Lifecycle events: apply on a legal transition, else count it invalid. */
private fun reduceTransition(
state: TaskState,
base: TaskState,
payload: TaskEvent,
): TaskState {
val from = state.status
return when (payload) {
is TaskClaimedEvent ->
base.transitionTo(TaskStatus.IN_PROGRESS, from == TaskStatus.TODO) {
it.copy(claimant = payload.claimant)
}
is TaskReleasedEvent ->
base.transitionTo(TaskStatus.TODO, from == TaskStatus.IN_PROGRESS) {
it.copy(claimant = null)
}
is TaskBlockedEvent ->
base.transitionTo(TaskStatus.BLOCKED, from in WORKING)
is TaskUnblockedEvent ->
base.transitionTo(
if (state.claimant != null) TaskStatus.IN_PROGRESS else TaskStatus.TODO,
from == TaskStatus.BLOCKED,
)
is TaskSubmittedForReviewEvent ->
base.transitionTo(TaskStatus.IN_REVIEW, from == TaskStatus.IN_PROGRESS)
is TaskCompletedEvent ->
base.transitionTo(TaskStatus.DONE, from == TaskStatus.IN_REVIEW)
is TaskReopenedEvent ->
base.transitionTo(TaskStatus.TODO, from in TERMINAL) {
it.copy(claimant = null)
}
is TaskCancelledEvent ->
base.transitionTo(TaskStatus.CANCELLED, from in CANCELLABLE)
else -> state
} ?: state.copy(invalidTransitions = state.invalidTransitions + 1)
}
private inline fun TaskState.transitionTo(
to: TaskStatus,
legal: Boolean,
mutate: (TaskState) -> TaskState = { it },
): TaskState? = if (legal) mutate(copy(status = to)) else null
private companion object {
val WORKING = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW)
val TERMINAL = setOf(TaskStatus.DONE, TaskStatus.CANCELLED)
val CANCELLABLE = setOf(
TaskStatus.TODO,
TaskStatus.IN_PROGRESS,
TaskStatus.IN_REVIEW,
TaskStatus.BLOCKED,
)
}
}
@@ -0,0 +1,26 @@
package com.correx.core.tasks
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.core.sessions.projections.replay.EventReplayer
/**
* Reads a project's task board by replaying its stream. Mirrors `DefaultSessionRepository`:
* the repository is a thin read model over an [EventReplayer]; the event log is the source
* of truth and the board is rebuilt from it.
*/
class DefaultTaskRepository(
private val replayer: EventReplayer<TaskBoard>,
private val streamId: SessionId,
) {
fun board(): TaskBoard = replayer.rebuild(streamId)
fun getTask(taskId: TaskId): Task? =
board()[taskId]?.takeUnless { it.deleted }?.let { Task(taskId, it) }
fun list(): List<Task> =
board().filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
fun count(): Int = list().size
}
@@ -0,0 +1,8 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
data class Task(
val taskId: TaskId,
val state: TaskState,
)
@@ -0,0 +1,6 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
/** All tasks in a project stream, keyed by id — the unit a single replay produces. */
typealias TaskBoard = Map<TaskId, TaskState>
@@ -0,0 +1,22 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.sessions.projections.Projection
/**
* Folds a per-project task stream into a [TaskBoard]. Each event is routed to its owning
* task by [TaskEvent.taskId]; non-task payloads leave the board untouched.
*/
class TaskBoardProjector(
private val reducer: TaskReducer,
) : Projection<TaskBoard> {
override fun initial(): TaskBoard = emptyMap()
override fun apply(state: TaskBoard, event: StoredEvent): TaskBoard {
val taskId = (event.payload as? TaskEvent)?.taskId ?: return state
val current = state[taskId] ?: TaskState()
return state + (taskId to reducer.reduce(current, event))
}
}
@@ -0,0 +1,109 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskTargetKind
/**
* Assembles a [TaskContextBundle] for a task. Links are partitioned by their recorded
* [com.correx.core.events.types.TaskTargetKind]: TASK targets are resolved to [RelatedTask]
* dependencies (with live status), DOC targets are resolved inline via [documentResolver], and
* anything unresolved (or ARTIFACT/SESSION, not inlined yet) becomes a raw [RelatedLink].
*
* When a [TaskKnowledgeRetriever] is supplied, the bundle is additionally enriched with
* semantically-relevant snippets over the task's title+goal. Retrieval and doc-resolution failures
* are swallowed (the bundle still returns) so context assembly never fails on a flaky dependency.
*/
class TaskContextAssembler(
private val service: TaskService,
private val retriever: TaskKnowledgeRetriever? = null,
private val documentResolver: TaskDocumentResolver? = null,
private val knowledgeLimit: Int = DEFAULT_KNOWLEDGE_LIMIT,
) {
suspend fun assemble(taskId: TaskId): TaskContextBundle? {
val state = service.getTask(taskId)?.state ?: return null
val dependencies = mutableListOf<RelatedTask>()
val documents = mutableListOf<TaskDocument>()
val related = mutableListOf<RelatedLink>()
for (link in state.links) {
// Resolution dispatches on the link's recorded targetKind — no guessing from the id.
// A kind whose resolution comes up empty (missing task, unresolvable doc, or an
// ARTIFACT/SESSION we don't inline yet) falls back to a raw related link.
when (link.targetKind) {
TaskTargetKind.TASK -> addTask(link, dependencies, related)
TaskTargetKind.DOC -> addDocument(link, documents, related)
TaskTargetKind.ARTIFACT, TaskTargetKind.SESSION ->
related += RelatedLink(link.targetId, link.type.name)
}
}
return TaskContextBundle(
id = taskId.value,
key = state.key,
status = state.status.name,
title = state.title,
goal = state.goal,
acceptanceCriteria = state.acceptanceCriteria,
relevantFiles = state.affectedPaths,
dependencies = dependencies,
documents = documents,
relatedLinks = related,
notes = state.notes.map { NoteEntry(it.author.name, it.body) },
relevantKnowledge = retrieveKnowledge(state),
)
}
private fun addTask(
link: TaskLink,
dependencies: MutableList<RelatedTask>,
related: MutableList<RelatedLink>,
) {
val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull()
if (linked != null) {
dependencies += RelatedTask(
id = linked.taskId.value,
status = linked.state.status.name,
title = linked.state.title,
link = link.type.name,
)
} else {
related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun addDocument(
link: TaskLink,
documents: MutableList<TaskDocument>,
related: MutableList<RelatedLink>,
) {
val doc = resolveDocument(link.targetId)
if (doc != null) {
documents += TaskDocument(
targetId = link.targetId,
type = link.type.name,
title = doc.title,
path = doc.path,
excerpt = doc.excerpt,
)
} else {
related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun resolveDocument(targetId: String): ResolvedDocument? {
val resolver = documentResolver ?: return null
return runCatching { resolver.resolve(targetId) }.getOrNull()
}
private suspend fun retrieveKnowledge(state: TaskState): List<KnowledgeHit> {
val active = retriever ?: return emptyList()
val query = listOfNotNull(state.title, state.goal).joinToString(" ").trim()
if (query.isEmpty()) return emptyList()
return runCatching { active.retrieve(query, knowledgeLimit) }.getOrDefault(emptyList())
}
private companion object {
const val DEFAULT_KNOWLEDGE_LIMIT = 5
}
}
@@ -0,0 +1,83 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Token-optimized context bundle for a task — the single payload an agent needs to start work:
* the task itself, its acceptance criteria, resolved dependency tasks, the related (non-task)
* links it should fetch, the relevant files, and notes. Assembled by [TaskContextAssembler].
*/
@Serializable
data class TaskContextBundle(
val id: String,
val key: String?,
val status: String,
val title: String?,
val goal: String?,
val acceptanceCriteria: List<String>,
val relevantFiles: List<String>,
val dependencies: List<RelatedTask>,
val documents: List<TaskDocument>,
val relatedLinks: List<RelatedLink>,
val notes: List<NoteEntry>,
val relevantKnowledge: List<KnowledgeHit> = emptyList(),
) {
/** Compact, label-per-line rendering for tool output (cheaper than JSON for the model). */
fun render(): String = buildString {
appendLine("task $id [$status] ${title.orEmpty()}".trimEnd())
goal?.let { appendLine("goal: $it") }
appendList("acceptance_criteria", acceptanceCriteria) { " - $it" }
if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}")
appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" }
appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" }
appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" }
appendList("notes", notes) { " - [${it.author}] ${it.body}" }
}.trimEnd()
private fun <T> StringBuilder.appendList(label: String, items: List<T>, line: (T) -> String) {
if (items.isEmpty()) return
appendLine("$label:")
items.forEach { appendLine(line(it)) }
}
}
/** A linked task resolved against the board: enough for the agent to judge readiness. */
@Serializable
data class RelatedTask(
val id: String,
val status: String,
val title: String?,
val link: String,
)
/** A link whose target resolved to a document (ADR/doc): title + excerpt inlined for the agent. */
@Serializable
data class TaskDocument(
val targetId: String,
val type: String,
val title: String,
val path: String,
val excerpt: String,
)
/** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */
@Serializable
data class RelatedLink(
val targetId: String,
val type: String,
)
@Serializable
data class NoteEntry(
val author: String,
val body: String,
)
/** A semantically-retrieved snippet relevant to the task's goal (e.g. a repo file or doc). */
@Serializable
data class KnowledgeHit(
val source: String,
val text: String,
val score: Double,
)
@@ -0,0 +1,24 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.sessions.projections.Projection
/**
* Counts created tasks in a project stream. The numeric suffix of a human key
* (`<prefix>-<n>`) is taken from this count, so ids never collide even after a task is
* cancelled — it counts creations, not currently-live tasks.
*/
class TaskCounterProjection(
private val projectId: String,
) : Projection<TaskCounterState> {
override fun initial(): TaskCounterState =
TaskCounterState(projectId, 0)
override fun apply(
state: TaskCounterState,
event: StoredEvent,
): TaskCounterState =
if (event.payload is TaskCreatedEvent) state.copy(count = state.count + 1) else state
}
@@ -0,0 +1,6 @@
package com.correx.core.tasks
data class TaskCounterState(
val projectId: String,
val count: Int,
)
@@ -0,0 +1,21 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Port for resolving a link target (e.g. "adr-7" or a "docs/…/x.md" path) to an inline document
* summary, so the context bundle can carry the doc's title + excerpt instead of a bare id the
* agent has to fetch separately. Implemented in a higher layer (apps/server reads the repo's
* docs); `core:tasks` stays decoupled from the filesystem. Returns null when the target is not a
* resolvable document — the link then stays a raw related link.
*/
interface TaskDocumentResolver {
suspend fun resolve(targetId: String): ResolvedDocument?
}
@Serializable
data class ResolvedDocument(
val title: String,
val path: String,
val excerpt: String,
)
@@ -0,0 +1,11 @@
package com.correx.core.tasks
/**
* Port for semantic enrichment of a task context bundle. Implemented in a higher layer
* (apps/server bridges it to the L3 vector retriever) and injected into [TaskContextAssembler];
* `core:tasks` stays decoupled from the retrieval stack. Implementations must be side-effect-free
* and tolerate being called with an arbitrary natural-language query.
*/
interface TaskKnowledgeRetriever {
suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit>
}
@@ -0,0 +1,11 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
/** One edge in the work graph: a typed pointer from a task to another entity, tagged by kind. */
data class TaskLink(
val targetId: String,
val type: TaskLinkType,
val targetKind: TaskTargetKind,
)
@@ -0,0 +1,11 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskNoteAuthor
import kotlinx.datetime.Instant
/** A note on a task, kept in agent/human lanes so consumers can filter by author. */
data class TaskNote(
val author: TaskNoteAuthor,
val body: String,
val at: Instant,
)
@@ -0,0 +1,10 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
interface TaskReducer {
fun reduce(
state: TaskState,
event: StoredEvent,
): TaskState
}
@@ -0,0 +1,166 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import kotlinx.datetime.Clock
import java.util.UUID
/**
* Write path for tasks: turns intents into events appended to the shared [EventStore].
* The event log stays the single source of truth — the board is rebuilt by replay, never
* stored. All task events for a project live in one stream ([TaskStreams.forProject]).
*
* Keys are `<project>-<n>`, so a task's project is recoverable from its id; mutating
* methods therefore need only the [TaskId]. They return the rebuilt [Task], or null when the
* task does not exist (so callers — e.g. tools — can report a clean failure). Mutations that
* are illegal for the current status are still appended but are no-ops on replay (the reducer
* counts them in `invalidTransitions`).
*/
@Suppress("TooManyFunctions")
class TaskService(
private val eventStore: EventStore,
private val clock: Clock = Clock.System,
) {
private val boardReplayer =
DefaultEventReplayer(eventStore, TaskBoardProjector(DefaultTaskReducer()))
fun board(projectId: ProjectId): TaskBoard =
boardReplayer.rebuild(TaskStreams.forProject(projectId))
fun list(projectId: ProjectId): List<Task> =
board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
fun getTask(taskId: TaskId): Task? {
val state = board(projectOf(taskId))[taskId] ?: return null
return if (state.deleted) null else Task(taskId, state)
}
suspend fun createTask(
projectId: ProjectId,
title: String,
goal: String,
acceptanceCriteria: List<String> = emptyList(),
affectedPaths: List<String> = emptyList(),
): Task {
val key = nextKey(projectId)
val taskId = TaskId(key)
append(
projectId,
TaskCreatedEvent(
taskId = taskId,
projectId = projectId,
key = key,
title = title,
goal = goal,
acceptanceCriteria = acceptanceCriteria,
affectedPaths = affectedPaths,
),
)
return requireNotNull(getTask(taskId)) { "task $key missing after creation" }
}
suspend fun edit(taskId: TaskId, title: String? = null, goal: String? = null): Task? =
mutate(taskId) { TaskEditedEvent(it, title = title, goal = goal) }
suspend fun setAcceptanceCriteria(taskId: TaskId, criteria: List<String>): Task? =
mutate(taskId) { TaskAcceptanceCriteriaSetEvent(it, criteria) }
suspend fun setAffectedPaths(taskId: TaskId, paths: List<String>): Task? =
mutate(taskId) { TaskAffectedPathsSetEvent(it, paths) }
suspend fun claim(taskId: TaskId, claimant: String): Task? =
mutate(taskId) { TaskClaimedEvent(it, claimant) }
suspend fun release(taskId: TaskId): Task? =
mutate(taskId) { TaskReleasedEvent(it) }
suspend fun block(taskId: TaskId, reason: String): Task? =
mutate(taskId) { TaskBlockedEvent(it, reason) }
suspend fun unblock(taskId: TaskId): Task? =
mutate(taskId) { TaskUnblockedEvent(it) }
suspend fun submitForReview(taskId: TaskId): Task? =
mutate(taskId) { TaskSubmittedForReviewEvent(it) }
suspend fun complete(taskId: TaskId): Task? =
mutate(taskId) { TaskCompletedEvent(it) }
suspend fun reopen(taskId: TaskId): Task? =
mutate(taskId) { TaskReopenedEvent(it) }
suspend fun cancel(taskId: TaskId, reason: String? = null): Task? =
mutate(taskId) { TaskCancelledEvent(it, reason) }
suspend fun link(taskId: TaskId, targetId: String, type: TaskLinkType, targetKind: TaskTargetKind): Task? =
mutate(taskId) { TaskLinkedEvent(it, targetId, type, targetKind) }
suspend fun unlink(taskId: TaskId, targetId: String, type: TaskLinkType): Task? =
mutate(taskId) { TaskUnlinkedEvent(it, targetId, type) }
suspend fun addNote(taskId: TaskId, author: TaskNoteAuthor, body: String): Task? =
mutate(taskId) { TaskNoteAddedEvent(it, author, body) }
/** Soft delete: appends a tombstone. After this, [getTask] returns null and [list] omits it. */
suspend fun delete(taskId: TaskId): Boolean {
if (getTask(taskId) == null) return false
append(projectOf(taskId), TaskDeletedEvent(taskId))
return true
}
private suspend fun mutate(taskId: TaskId, event: (TaskId) -> EventPayload): Task? {
if (getTask(taskId) == null) return null
append(projectOf(taskId), event(taskId))
return getTask(taskId)
}
private fun nextKey(projectId: ProjectId): String {
val count = DefaultEventReplayer(eventStore, TaskCounterProjection(projectId.value))
.rebuild(TaskStreams.forProject(projectId)).count
return "${projectId.value}-${count + 1}"
}
private fun projectOf(taskId: TaskId): ProjectId =
ProjectId(taskId.value.substringBeforeLast('-'))
private suspend fun append(projectId: ProjectId, payload: EventPayload) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = TaskStreams.forProject(projectId),
timestamp = clock.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = payload,
),
)
}
}
@@ -0,0 +1,26 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import kotlinx.datetime.Instant
/**
* Projected state of a single task, rebuilt by folding its events through [TaskReducer].
* Mirrors the shape of `SessionState`: defaults make the empty/pre-creation state valid.
*/
data class TaskState(
val status: TaskStatus = TaskStatus.TODO,
val key: String? = null,
val projectId: ProjectId? = null,
val title: String? = null,
val goal: String? = null,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
val links: List<TaskLink> = emptyList(),
val claimant: String? = null,
val notes: List<TaskNote> = emptyList(),
val createdAt: Instant? = null,
val updatedAt: Instant? = null,
val invalidTransitions: Int = 0,
/** Soft-deleted tombstone — repository reads hide these; the events remain in the log. */
val deleted: Boolean = false,
)
@@ -0,0 +1,16 @@
package com.correx.core.tasks
/**
* Derived task lifecycle status. Never serialized on events — the reducer computes it from
* the lifecycle facts. Workflow: TODO → IN_PROGRESS → IN_REVIEW → DONE, with BLOCKED
* (reversible) and CANCELLED (terminal).
*/
enum class TaskStatus {
TODO,
IN_PROGRESS,
IN_REVIEW,
BLOCKED,
DONE,
CANCELLED,
;
}
@@ -0,0 +1,16 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
/**
* Task events live in one event stream per project, keyed `tasks:<projectId>`. The
* `tasks:` prefix keeps these streams distinguishable from real agent sessions (the
* EventStore is partitioned by [SessionId], and [SessionId]/`TaskId` are both `TypeId`).
*/
object TaskStreams {
const val PREFIX: String = "tasks:"
fun forProject(projectId: ProjectId): SessionId =
SessionId("$PREFIX${projectId.value}")
}
@@ -0,0 +1,147 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class DefaultTaskReducerTest {
private val reducer = DefaultTaskReducer()
private val taskId = TaskId("auth-1")
private val projectId = ProjectId("auth")
private fun stored(payload: EventPayload, seq: Long = 1L) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-0${seq}T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun reduceAll(vararg payloads: EventPayload): TaskState =
payloads.foldIndexed(TaskState()) { i, state, payload ->
reducer.reduce(state, stored(payload, (i + 1).toLong()))
}
private fun created() = TaskCreatedEvent(
taskId = taskId,
projectId = projectId,
key = "auth-1",
title = "Implement JWT refresh flow",
goal = "users stay authenticated",
)
@Test
fun `TaskCreatedEvent sets fields and TODO status`() {
val state = reduceAll(created())
assertEquals(TaskStatus.TODO, state.status)
assertEquals("auth-1", state.key)
assertEquals(projectId, state.projectId)
assertEquals("Implement JWT refresh flow", state.title)
assertEquals("users stay authenticated", state.goal)
assertEquals(Instant.parse("2026-01-01T00:00:00Z"), state.createdAt)
assertEquals(0, state.invalidTransitions)
}
@Test
fun `claiming moves TODO to IN_PROGRESS and records claimant`() {
val state = reduceAll(created(), TaskClaimedEvent(taskId, claimant = "claude-opus"))
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals("claude-opus", state.claimant)
}
@Test
fun `happy path created to claimed to review to done`() {
val state = reduceAll(
created(),
TaskClaimedEvent(taskId, claimant = "claude-opus"),
TaskSubmittedForReviewEvent(taskId),
TaskCompletedEvent(taskId),
)
assertEquals(TaskStatus.DONE, state.status)
assertEquals(0, state.invalidTransitions)
}
@Test
fun `unblock restores the working status of a claimed task`() {
val state = reduceAll(
created(),
TaskClaimedEvent(taskId, claimant = "claude-opus"),
TaskBlockedEvent(taskId, reason = "waiting on auth-138"),
TaskUnblockedEvent(taskId),
)
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals(0, state.invalidTransitions)
}
@Test
fun `illegal transition is ignored and counted`() {
val state = reduceAll(created(), TaskCompletedEvent(taskId))
assertEquals(TaskStatus.TODO, state.status)
assertEquals(1, state.invalidTransitions)
}
@Test
fun `links and notes accumulate without affecting status`() {
val state = reduceAll(
created(),
TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")),
TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK),
TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"),
)
assertEquals(TaskStatus.TODO, state.status)
assertEquals(listOf("refresh endpoint exists"), state.acceptanceCriteria)
assertEquals(1, state.links.size)
assertEquals(TaskLink("auth-138", TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK), state.links.single())
assertEquals(1, state.notes.size)
assertEquals(TaskNoteAuthor.AGENT, state.notes.single().author)
}
@Test
fun `non-task payload leaves state untouched`() {
val before = reduceAll(created())
val nonTask = RefinementIterationEvent(
sessionId = SessionId("s-1"),
cycleKey = "implement->review",
iteration = 1,
maxIterations = 3,
)
val after = reducer.reduce(before, stored(nonTask, seq = 9L))
assertEquals(before, after)
assertNull(after.claimant)
}
}
@@ -0,0 +1,50 @@
package com.correx.core.tasks
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/** Minimal append-capable in-memory event store for core:tasks tests, keyed per session. */
internal class InMemoryEventStore : EventStore {
private val events = mutableListOf<StoredEvent>()
private var global = 0L
private val perSession = mutableMapOf<SessionId, Long>()
override suspend fun append(event: NewEvent): StoredEvent {
global += 1
val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1
perSession[event.metadata.sessionId] = seq
val stored = StoredEvent(
metadata = event.metadata,
sequence = global,
sessionSequence = seq,
payload = event.payload,
)
events += stored
return stored
}
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
read(sessionId).filter { it.sessionSequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? =
read(sessionId).maxOfOrNull { it.sessionSequence }
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = global
override fun allEvents(): Sequence<StoredEvent> = events.asSequence()
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
}
@@ -0,0 +1,74 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TaskBoardProjectorTest {
private val projector = TaskBoardProjector(DefaultTaskReducer())
private val projectId = ProjectId("auth")
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun created(id: TaskId) =
TaskCreatedEvent(id, projectId, id.value, "title ${id.value}", "goal")
private fun fold(vararg events: StoredEvent): TaskBoard =
events.fold(projector.initial()) { board, event -> projector.apply(board, event) }
@Test
fun `interleaved events update each task independently`() {
val one = TaskId("auth-1")
val two = TaskId("auth-2")
val board = fold(
stored(created(one), 1),
stored(created(two), 2),
stored(TaskClaimedEvent(one, claimant = "claude-opus"), 3),
)
assertEquals(2, board.size)
assertEquals(TaskStatus.IN_PROGRESS, board[one]?.status)
assertEquals("claude-opus", board[one]?.claimant)
assertEquals(TaskStatus.TODO, board[two]?.status)
}
@Test
fun `non-task payload leaves the board unchanged`() {
val one = TaskId("auth-1")
val withTask = fold(stored(created(one), 1))
val nonTask = RefinementIterationEvent(
sessionId = SessionId("s-1"),
cycleKey = "implement->review",
iteration = 1,
maxIterations = 3,
)
val after = projector.apply(withTask, stored(nonTask, 2))
assertEquals(withTask, after)
}
}
@@ -0,0 +1,128 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskContextAssemblerTest {
private val store = InMemoryEventStore()
private val service = TaskService(store)
private val assembler = TaskContextAssembler(service)
private val project = ProjectId("auth")
@Test
fun `bundle resolves linked tasks as dependencies and keeps non-task links raw`() = runBlocking {
val dep = service.createTask(project, "Design auth model", "model")
service.claim(dep.taskId, "claude-opus")
service.submitForReview(dep.taskId)
service.complete(dep.taskId) // dep is DONE
val task = service.createTask(
project,
title = "Implement JWT refresh",
goal = "users stay authenticated",
acceptanceCriteria = listOf("refresh endpoint exists"),
affectedPaths = listOf("backend/auth/**"),
)
service.link(task.taskId, dep.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
// DOC-tagged, but this assembler has no document resolver → stays a raw related link
service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC)
service.addNote(task.taskId, TaskNoteAuthor.AGENT, "kickoff")
val bundle = assembler.assemble(task.taskId)!!
assertEquals("auth-2", bundle.id)
assertEquals("users stay authenticated", bundle.goal)
assertEquals(listOf("refresh endpoint exists"), bundle.acceptanceCriteria)
assertEquals(listOf("backend/auth/**"), bundle.relevantFiles)
val dependency = bundle.dependencies.single()
assertEquals("auth-1", dependency.id)
assertEquals("DONE", dependency.status)
assertEquals("DEPENDS_ON", dependency.link)
assertEquals(RelatedLink("adr-7", "IMPLEMENTS"), bundle.relatedLinks.single())
assertEquals("kickoff", bundle.notes.single().body)
}
@Test
fun `render produces a compact labelled bundle`() = runBlocking {
val task = service.createTask(project, "JWT refresh", "stay authed", listOf("rotates"))
val text = assembler.assemble(task.taskId)!!.render()
assertTrue(text.startsWith("task auth-1 [TODO] JWT refresh"))
assertTrue(text.contains("goal: stay authed"))
assertTrue(text.contains("- rotates"))
}
@Test
fun `assemble returns null for an unknown task`() = runBlocking {
assertNull(assembler.assemble(TaskId("auth-999")))
}
@Test
fun `bundle is enriched with knowledge retrieved over the goal`() = runBlocking {
val captured = mutableListOf<String>()
val fakeRetriever = object : TaskKnowledgeRetriever {
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> {
captured += query
return listOf(KnowledgeHit("backend/auth/Jwt.kt", "fun refresh(token)", 0.91))
}
}
val enriched = TaskContextAssembler(service, fakeRetriever)
val task = service.createTask(project, "JWT refresh", "users stay authenticated")
val bundle = enriched.assemble(task.taskId)!!
assertEquals(listOf("JWT refresh users stay authenticated"), captured)
assertEquals("backend/auth/Jwt.kt", bundle.relevantKnowledge.single().source)
assertTrue(bundle.render().contains("relevant_knowledge"))
}
@Test
fun `linked docs are resolved inline and dropped from raw related links`() = runBlocking {
val docs = object : TaskDocumentResolver {
override suspend fun resolve(targetId: String): ResolvedDocument? =
if (targetId == "adr-7") {
ResolvedDocument("ADR 7: use redis", "docs/decisions/adr-0007-redis.md", "faster invalidation")
} else {
null
}
}
val enriched = TaskContextAssembler(service, documentResolver = docs)
val task = service.createTask(project, "JWT refresh", "auth")
service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC) // resolves to a doc
service.link(task.taskId, "ext-123", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // stays raw
val bundle = enriched.assemble(task.taskId)!!
val doc = bundle.documents.single()
assertEquals("adr-7", doc.targetId)
assertEquals("ADR 7: use redis", doc.title)
assertEquals("IMPLEMENTS", doc.type)
assertEquals(RelatedLink("ext-123", "RELATES_TO"), bundle.relatedLinks.single())
assertTrue(bundle.render().contains("documents:"))
}
@Test
fun `retrieval failure is swallowed and the bundle still returns`() = runBlocking {
val flaky = object : TaskKnowledgeRetriever {
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
error("embedder offline")
}
val task = service.createTask(project, "JWT refresh", "stay authed")
val bundle = TaskContextAssembler(service, flaky).assemble(task.taskId)!!
assertTrue(bundle.relevantKnowledge.isEmpty())
assertEquals("JWT refresh", bundle.title)
}
}
@@ -0,0 +1,51 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TaskCounterProjectionTest {
private val projection = TaskCounterProjection("auth")
private val projectId = ProjectId("auth")
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun created(id: TaskId) =
TaskCreatedEvent(id, projectId, id.value, "title", "goal")
@Test
fun `counts only created events and survives cancellation`() {
val events = listOf(
stored(created(TaskId("auth-1")), 1),
stored(created(TaskId("auth-2")), 2),
stored(TaskCancelledEvent(TaskId("auth-1")), 3),
)
val state = events.fold(projection.initial()) { s, e -> projection.apply(s, e) }
assertEquals("auth", state.projectId)
assertEquals(2, state.count)
}
}
@@ -0,0 +1,84 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskServiceTest {
private val store = InMemoryEventStore()
private val service = TaskService(store)
private val project = ProjectId("auth")
@Test
fun `createTask allocates sequential human keys per project`() = runBlocking {
val first = service.createTask(project, "Add JWT refresh", "users stay authenticated")
val second = service.createTask(project, "Rotate keys", "tokens rotate")
assertEquals("auth-1", first.taskId.value)
assertEquals("auth-2", second.taskId.value)
assertEquals(TaskStatus.TODO, first.state.status)
assertEquals("Add JWT refresh", first.state.title)
}
@Test
fun `lifecycle round-trips through append and replay`() = runBlocking {
val task = service.createTask(project, "Add JWT refresh", "users stay authenticated")
val id = task.taskId
assertEquals(TaskStatus.IN_PROGRESS, service.claim(id, "claude-opus")?.state?.status)
assertEquals(TaskStatus.IN_REVIEW, service.submitForReview(id)?.state?.status)
val done = service.complete(id)
assertEquals(TaskStatus.DONE, done?.state?.status)
assertEquals("claude-opus", done?.state?.claimant)
}
@Test
fun `links and notes are persisted on the task`() = runBlocking {
val id = service.createTask(project, "Add JWT refresh", "auth").taskId
service.link(id, targetId = "adr-7", type = TaskLinkType.IMPLEMENTS, targetKind = TaskTargetKind.DOC)
val withNote = service.addNote(id, TaskNoteAuthor.AGENT, "migration failed, retrying")
assertEquals(
TaskLink("adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC),
withNote?.state?.links?.single(),
)
assertEquals("migration failed, retrying", withNote?.state?.notes?.single()?.body)
}
@Test
fun `delete is a soft tombstone hidden from reads`() = runBlocking {
val id = service.createTask(project, "throwaway", "oops").taskId
assertTrue(service.delete(id))
assertNull(service.getTask(id))
assertTrue(service.list(project).none { it.taskId == id })
// A second delete is a no-op: the task is already gone from reads.
assertFalse(service.delete(id))
}
@Test
fun `mutating an unknown task returns null`() = runBlocking {
assertNull(service.claim(TaskId("auth-999"), "claude-opus"))
}
@Test
fun `keys keep incrementing even after deletion`() = runBlocking {
val first = service.createTask(project, "one", "g").taskId
service.delete(first)
val third = service.createTask(project, "two", "g")
// count() is creation-based, so the deleted id is never reused.
assertEquals("auth-2", third.taskId.value)
}
}
+112
View File
@@ -0,0 +1,112 @@
---
name: "Adr 0012 Task Tracking"
description: "Native task tracking as an event-sourced aggregate; tasks are a first-class work-graph node"
depth: 2
links: ["../index.md", "./adr-0001-event-sourcing.md", "./adr-0005-persistence-choice.md", "./adr-0006-event-store-append-only.md"]
---
# ADR 0012: native task tracking as an event-sourced aggregate
**status:** accepted
**date:** 23.06.2026 (June)
**deciders:** correx design team
---
## context
Correx is to gain **native task tracking** — a durable "task" (work item / issue) entity,
agent-first. Today the system has no concept of a task; it has `sessions` (agent runs) and
session/stage-scoped `artifacts`. A task is conceptually different: a durable unit of intent
that outlives any single session and *references* the artifacts and sessions that act on it.
An originating pitch proposed storing tasks as YAML/markdown files as the **source of truth**
with Postgres as a cache, plus a `GET /tasks/next` assignment scheduler. Those technical
choices are **rejected** here: they contradict correx's foundational invariants —
"the event log is the only source of truth; projections are disposable and rebuilt from
events" (`.correx/project.toml`, ADR-0001, ADR-0006). Adopting hand-edited files as truth
would create two masters (files vs. log) and forfeit replay/idempotency. We keep the *goal*
(native tasks) and realise it with correx's actual architecture.
This ADR covers the **foundational slice**: the aggregate, its events, and the work-graph
edge model. REST/CLI/agent-context-bundle/search are deliberately later phases.
## decision
**A task is a first-class event-sourced aggregate**, modelled on the existing `core:sessions`
aggregate (Status / State / Reducer / Projector / Repository / Counter), living in a new
`core:tasks` module. It is *not* an `ArtifactKind` — artifacts are session/stage-scoped LLM
outputs; a task is durable and cross-session.
### 1. event log is the source of truth
Tasks are rebuilt by folding events. Any future markdown/file representation is a *disposable
projection* (export), never the master copy.
### 2. per-project task stream
The `EventStore` is partitioned by `sessionId`. Since `SessionId` and `TaskId` are both
`TypeId`, all task events for a project live in one stream keyed `tasks:<projectId>`
(`TaskStreams.forProject`). One read rebuilds the whole board (`TaskBoard = Map<TaskId,
TaskState>`). The `tasks:` prefix keeps these streams distinguishable from real agent
sessions; when task streams are later wired into the live server, session-listing consumers
filter the prefix.
### 3. lifecycle as facts; status is derived
Mirroring sessions (whose events never carry `SessionStatus`), task events are facts
(`TaskClaimed`, `TaskBlocked`, `TaskCompleted`, …) and `DefaultTaskReducer` derives
`TaskStatus`. This keeps the `TaskStatus` enum out of the lowest module (`core:events`). A
`sealed interface TaskEvent { val taskId }` (a marker, *not* part of the serialized
`EventPayload` hierarchy) lets projections route any payload to its task without a giant `when`.
### 4. status workflow
`TODO → IN_PROGRESS → IN_REVIEW → DONE`, with `BLOCKED` (reversible; unblock returns to
IN_PROGRESS if claimed, else TODO) and `CANCELLED` (terminal, reopenable). Illegal
transitions are ignored and counted in `TaskState.invalidTransitions` (as `SessionState` does).
### 5. work-graph edges
`TaskLinkType` (sibling to the existing `ArtifactRelationshipType`):
`DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT`. A link target is a plain id
string — another task, an `ArtifactId`, or a `SessionId` — plus a type. This is the work-graph
backbone the agent-context bundle (later phase) will traverse.
### 6. human-friendly ids
`TaskCounterProjection(projectId)` counts `TaskCreatedEvent`s in the project stream; the
numeric suffix of a key (`<prefix>-<n>`, e.g. `auth-142`) is the count of *created* tasks, so
ids never collide even after cancellation. The creating caller composes the key and stores it
in `TaskCreatedEvent.key`. (The creating service is a later phase; the projection and `key`
field land now.)
## consequences
**positive:**
* reuses the entire event-sourcing backbone (sequencing, idempotency, replay, projections);
no new persistence model, no second source of truth
* the work graph (tasks ↔ tasks/artifacts/sessions) is native, enabling later
"everything touching X" queries and the agent-context bundle
* deterministic, replayable task state; status derivation is unit-testable in isolation
**negative:**
* overloading the `sessionId`-partitioned stream key with `tasks:<projectId>` means
session-enumerating consumers must learn to filter the prefix once tasks go live
(acceptable; isolated to the live-wiring phase)
* per-project (not per-task) stream means rebuilding one task replays the project's task
events — fine at expected volumes; revisit with snapshots if a project's task log grows large
## alternatives considered
* **Task as an `ArtifactKind`:** rejected — artifacts are session/stage-scoped outputs;
tasks are durable, cross-session, and carry their own lifecycle/claiming.
* **YAML/markdown files as source of truth + Postgres cache (the pitch):** rejected —
violates ADR-0001/0006; creates two masters and loses replay/idempotency.
* **`GET /tasks/next` assignment scheduler (the pitch):** dropped — scheduling/prioritisation
is a separate hard problem; v1 offers `claim` (pull), not `assign` (push).
* **One stream per task:** cleaner per-task sequencing but pollutes session enumeration with
one stream per task and makes board/list queries fan out across many streams; the
per-project stream is the better default for a tracker that lists/boards constantly.
## status
Governs the foundational task-tracking slice. Re-evaluation expected when the REST surface,
the `GET /tasks/{id}/context` agent bundle, search, and git-driven auto status updates are
designed (those phases build on, and must not contradict, this ADR).
@@ -35,6 +35,7 @@ import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.WorkflowSummary import com.correx.core.router.model.WorkflowSummary
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.DefaultEventReplayer import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.registry.ToolRegistry import com.correx.core.tools.registry.ToolRegistry
import com.correx.infrastructure.artifactscas.CasArtifactStore import com.correx.infrastructure.artifactscas.CasArtifactStore
@@ -104,8 +105,11 @@ object InfrastructureModule {
return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) } return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) }
} }
fun createToolRegistry(config: ToolConfig): ToolRegistry = fun createToolRegistry(
DefaultToolRegistry.build(config.buildTools()) config: ToolConfig,
extraTools: List<Tool> = emptyList(),
): ToolRegistry =
DefaultToolRegistry.build(config.buildTools() + extraTools)
fun createLlamaCppProvider( fun createLlamaCppProvider(
modelId: String, modelId: String,
+1
View File
@@ -13,6 +13,7 @@ dependencies {
implementation project(":core:events") implementation project(":core:events")
implementation project(":core:approvals") implementation project(":core:approvals")
implementation project(":core:sessions") implementation project(":core:sessions")
implementation project(":core:tasks")
implementation project(":infrastructure:tools:filesystem") implementation project(":infrastructure:tools:filesystem")
implementation project(":core:artifacts") implementation project(":core:artifacts")
implementation project(":core:artifacts-store") implementation project(":core:artifacts-store")
@@ -0,0 +1,56 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: fetch a task's token-optimized context bundle in one call. Read-only, so
* it sits at the lowest tier (like file_read) — agents call it before starting work on a task.
*/
class TaskContextTool(private val assembler: TaskContextAssembler) : Tool, ToolExecutor {
override val name: String = "task_context"
override val description: String =
"Fetch a task's context bundle (goal, acceptance criteria, resolved dependencies, " +
"related links, relevant files, notes) in one call. Use before working a task."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
val bundle = assembler.assemble(TaskId(id))
?: return ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
return ToolResult.Success(
invocationId = request.invocationId,
output = bundle.render(),
metadata = mapOf("taskId" to id, "status" to bundle.status),
)
}
}
@@ -0,0 +1,89 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/** Agent-facing tool: create a task in a project's work graph. */
class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_create"
override val description: String =
"Create a task in a project's work graph and return its id (e.g. 'auth-142')."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("project") {
put("type", "string")
put("description", "Project key, e.g. 'auth'. The new task id is '<project>-<n>'.")
}
putJsonObject("title") {
put("type", "string")
put("description", "Short imperative title.")
}
putJsonObject("goal") {
put("type", "string")
put("description", "What 'done' looks like, in a sentence or two.")
}
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Concrete, verifiable criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Globs/paths this task is expected to touch.")
}
}
put(
"required",
buildJsonArray {
add(JsonPrimitive("project"))
add(JsonPrimitive("title"))
add(JsonPrimitive("goal"))
},
)
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).")
request.stringParam("title") ?: return ValidationResult.Invalid("Missing 'title' (string).")
request.stringParam("goal") ?: return ValidationResult.Invalid("Missing 'goal' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val invalid = validateRequest(request) as? ValidationResult.Invalid
if (invalid != null) {
return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
}
val task = service.createTask(
projectId = ProjectId(request.stringParam("project")!!),
title = request.stringParam("title")!!,
goal = request.stringParam("goal")!!,
acceptanceCriteria = request.listParam("acceptance_criteria"),
affectedPaths = request.listParam("affected_paths"),
)
return ToolResult.Success(
invocationId = request.invocationId,
output = "Created ${task.taskId.value}: ${task.state.title}",
metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name),
)
}
}
@@ -0,0 +1,55 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: soft-delete a task (tombstone). History is preserved in the event log;
* the task drops out of active views. Use 'task_update' with action=cancel to record an
* abandoned-but-visible outcome instead.
*/
class TaskDeleteTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_delete"
override val description: String =
"Soft-delete a task (removes it from active views; history is kept). " +
"For an abandoned-but-visible task, use task_update action=cancel instead."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
return if (service.delete(TaskId(id))) {
ToolResult.Success(request.invocationId, output = "Deleted $id", metadata = mapOf("taskId" to id))
} else {
ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
}
}
}
@@ -0,0 +1,26 @@
package com.correx.infrastructure.tools.task
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskDocumentResolver
import com.correx.core.tasks.TaskKnowledgeRetriever
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
/** Builds the task tool set over a single [TaskService] for registration in the tool registry. */
object TaskTools {
/**
* [retriever] semantically enriches the `task_context` bundle; [documentResolver] inlines
* linked ADRs/docs. Both optional — null leaves the bundle deterministic.
*/
fun forService(
service: TaskService,
retriever: TaskKnowledgeRetriever? = null,
documentResolver: TaskDocumentResolver? = null,
): List<Tool> =
listOf(
TaskCreateTool(service),
TaskUpdateTool(service),
TaskDeleteTool(service),
TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)),
)
}
@@ -0,0 +1,158 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$")
/**
* Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph
* link, or attach a note. The task's project is derived from its id, so only the id is required.
*/
class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_update"
override val description: String =
"Update a task: edit title/goal/criteria/paths, change status via 'action' " +
"(claim, release, block, unblock, submit_for_review, complete, reopen, cancel), " +
"add a link, or add a note."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
putJsonObject("title") { put("type", "string"); put("description", "New title.") }
putJsonObject("goal") { put("type", "string"); put("description", "New goal.") }
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Replaces the acceptance criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Replaces the affected paths.")
}
putJsonObject("action") {
put("type", "string")
put(
"description",
"Status transition: claim, release, block, unblock, submit_for_review, " +
"complete, reopen, cancel.",
)
}
putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") }
putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") }
putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") }
putJsonObject("link_type") {
put("type", "string")
put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.")
}
putJsonObject("link_kind") {
put("type", "string")
put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.")
}
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return fail(request, "Missing 'id' (string).")
val taskId = TaskId(id)
if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
applyFieldEdits(request, taskId)
val action = request.stringParam("action")
if (action != null && !applyAction(taskId, action, request)) {
return fail(request, "Unknown action '$action'.")
}
val linkTarget = request.stringParam("link_target")
if (linkTarget != null) {
val type = parseLinkType(request.stringParam("link_type"))
?: return fail(request, "Invalid 'link_type'.")
val kind = resolveTargetKind(request.stringParam("link_kind"), linkTarget)
?: return fail(request, "Invalid 'link_kind'.")
service.link(taskId, linkTarget, type, kind)
}
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
return ToolResult.Success(
invocationId = request.invocationId,
output = "Updated $id [$status]",
metadata = mapOf("taskId" to id, "status" to status),
)
}
private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) {
val title = request.stringParam("title")
val goal = request.stringParam("goal")
if (title != null || goal != null) service.edit(taskId, title, goal)
if (request.parameters.containsKey("acceptance_criteria")) {
service.setAcceptanceCriteria(taskId, request.listParam("acceptance_criteria"))
}
if (request.parameters.containsKey("affected_paths")) {
service.setAffectedPaths(taskId, request.listParam("affected_paths"))
}
}
private suspend fun applyAction(taskId: TaskId, action: String, request: ToolRequest): Boolean {
val claimant = request.stringParam("claimant") ?: request.sessionId.value
val reason = request.stringParam("reason")
when (action) {
"claim" -> service.claim(taskId, claimant)
"release" -> service.release(taskId)
"block" -> service.block(taskId, reason ?: "blocked")
"unblock" -> service.unblock(taskId)
"submit_for_review" -> service.submitForReview(taskId)
"complete" -> service.complete(taskId)
"reopen" -> service.reopen(taskId)
"cancel" -> service.cancel(taskId, reason)
else -> return false
}
return true
}
private fun parseLinkType(raw: String?): TaskLinkType? =
if (raw == null) TaskLinkType.RELATES_TO
else TaskLinkType.entries.firstOrNull { it.name == raw.uppercase() }
/** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */
private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? =
if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() }
else inferTargetKind(target)
private fun inferTargetKind(target: String): TaskTargetKind =
if (ADR_ID.matches(target) || target.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
}
@@ -0,0 +1,11 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.events.ToolRequest
/** A non-blank string parameter, or null when absent/blank/not a string. */
internal fun ToolRequest.stringParam(name: String): String? =
(parameters[name] as? String)?.takeIf { it.isNotBlank() }
/** A string-list parameter (JSON array), coerced element-wise; empty when absent. */
internal fun ToolRequest.listParam(name: String): List<String> =
(parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList()
@@ -0,0 +1,168 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tools.contract.ToolResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskToolsTest {
private val service = TaskService(InMemoryEventStore())
private val create = TaskCreateTool(service)
private val update = TaskUpdateTool(service)
private val delete = TaskDeleteTool(service)
private val context = TaskContextTool(TaskContextAssembler(service))
private var counter = 0
private fun request(tool: String, params: Map<String, Any>) = ToolRequest(
invocationId = ToolInvocationId("inv-${counter++}"),
sessionId = SessionId("s-1"),
stageId = StageId("stage-1"),
toolName = tool,
parameters = params,
)
private suspend fun createTask(): String {
val result = create.execute(
request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")),
)
assertTrue(result is ToolResult.Success)
return (result as ToolResult.Success).metadata.getValue("taskId")
}
@Test
fun `task_create creates a task and returns its id`() = runBlocking {
val id = createTask()
assertEquals("auth-1", id)
assertEquals(TaskStatus.TODO, service.getTask(com.correx.core.events.types.TaskId(id))?.state?.status)
}
@Test
fun `task_create rejects missing required fields`() = runBlocking {
val result = create.execute(request("task_create", mapOf("project" to "auth")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_update applies status action, edits, link and note`() = runBlocking {
val id = createTask()
val taskId = com.correx.core.events.types.TaskId(id)
val result = update.execute(
request(
"task_update",
mapOf(
"id" to id,
"action" to "claim",
"claimant" to "claude-opus",
"title" to "JWT refresh flow",
"link_target" to "adr-7",
"link_type" to "implements",
"note" to "starting",
),
),
)
assertTrue(result is ToolResult.Success)
val state = service.getTask(taskId)!!.state
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals("claude-opus", state.claimant)
assertEquals("JWT refresh flow", state.title)
assertEquals("adr-7", state.links.single().targetId)
assertEquals("starting", state.notes.single().body)
}
@Test
fun `task_update rejects an unknown action`() = runBlocking {
val id = createTask()
val result = update.execute(request("task_update", mapOf("id" to id, "action" to "frobnicate")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_update on a missing task fails`() = runBlocking {
val result = update.execute(request("task_update", mapOf("id" to "auth-999", "title" to "x")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_context returns a rendered bundle with dependencies`() = runBlocking {
val id = createTask()
update.execute(
request("task_update", mapOf("id" to id, "link_target" to "adr-7", "link_type" to "implements")),
)
val result = context.execute(request("task_context", mapOf("id" to id)))
assertTrue(result is ToolResult.Success)
val output = (result as ToolResult.Success).output
assertTrue(output.startsWith("task $id [TODO]"))
assertTrue(output.contains("adr-7 (IMPLEMENTS)"))
}
@Test
fun `task_context on a missing task fails`() = runBlocking {
assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure)
}
@Test
fun `task_delete tombstones the task`() = runBlocking {
val id = createTask()
val deleted = delete.execute(request("task_delete", mapOf("id" to id)))
assertTrue(deleted is ToolResult.Success)
assertNull(service.getTask(com.correx.core.events.types.TaskId(id)))
// Deleting again now fails: it is gone from active views.
assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure)
}
}
/** Minimal append-capable in-memory store for tool tests. */
private class InMemoryEventStore : EventStore {
private val events = mutableListOf<StoredEvent>()
private var global = 0L
private val perSession = mutableMapOf<SessionId, Long>()
override suspend fun append(event: NewEvent): StoredEvent {
global += 1
val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1
perSession[event.metadata.sessionId] = seq
val stored = StoredEvent(event.metadata, global, seq, event.payload)
events += stored
return stored
}
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
read(sessionId).filter { it.sessionSequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence }
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = global
override fun allEvents(): Sequence<StoredEvent> = events.asSequence()
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
}
+1
View File
@@ -23,6 +23,7 @@ include ':core:tools'
include ':core:toolintent' include ':core:toolintent'
include ':core:router' include ':core:router'
include ':core:sessions' include ':core:sessions'
include ':core:tasks'
include ':core:config' include ':core:config'
include ':core:risk' include ':core:risk'
include ':core:journal' include ':core:journal'