fix(server): bind project profile on chat-session start

Chat sessions never emitted ProjectProfileBoundEvent — only the workflow
path did — so router triage always saw a null profile and could not cite
conventions or commands. Extract the binding from launchSessionRun into
ServerModule.bindProjectProfile and call it from handleStartChatSession.
This commit is contained in:
2026-06-11 22:09:34 +04:00
parent 07819ec82d
commit 50602b0f11
2 changed files with 39 additions and 29 deletions
@@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(ServerModule::class.java)
@@ -220,35 +221,7 @@ class ServerModule(
),
)
}
// Bind the per-repo project profile (curated .correx/project.toml) as an event so
// stages and replay read the recorded snapshot, never the live file (invariants #8/#9).
val workspaceRoot = runCatching {
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
}.getOrNull() ?: projectMemory?.repoRoot()
workspaceRoot?.let { root ->
val projectProfile = ProjectProfileLoader.load(root)
if (!projectProfile.isEmpty()) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ProjectProfileBoundEvent(
sessionId = sessionId,
workspaceRoot = root,
about = projectProfile.about,
conventions = projectProfile.conventions,
commands = projectProfile.commands,
),
),
)
}
}
bindProjectProfile(sessionId)
runCatching {
orchestrator.run(sessionId, graph, sessionConfig)
// After a successful freestyle planning run, lock the plan and execute phase 2.
@@ -290,6 +263,38 @@ class ServerModule(
}
}
/**
* Bind the per-repo project profile (curated .correx/project.toml) as an event so stages,
* router chat triage, and replay read the recorded snapshot, never the live file
* (invariants #8/#9). Shared by the workflow path and the chat-session path.
*/
suspend fun bindProjectProfile(sessionId: SessionId) {
val workspaceRoot = runCatching {
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
}.getOrNull() ?: projectMemory?.repoRoot() ?: return
val projectProfile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
if (projectProfile.isEmpty()) return
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ProjectProfileBoundEvent(
sessionId = sessionId,
workspaceRoot = workspaceRoot,
about = projectProfile.about,
conventions = projectProfile.conventions,
commands = projectProfile.commands,
),
),
)
}
private fun launchSessionResume(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
activeSessionJobs.computeIfAbsent(sessionId) {
moduleScope.launch {
@@ -379,6 +379,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
payload = ChatSessionStartedEvent(sessionId = sessionId),
))
// Bind the per-repo project profile so router triage can cite conventions/commands.
// Parity with launchSessionRun — chat sessions previously never bound a profile.
runCatching { module.bindProjectProfile(sessionId) }
.onFailure { log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message) }
runCatching {
module.routerFacade.onUserInput(sessionId, msg.text)
}.onFailure {