fix(server): unify session workspace root (#266)
This commit is contained in:
@@ -21,7 +21,7 @@ All sources under `apps/server/src/`.
|
|||||||
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
|
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
|
||||||
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
|
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
|
||||||
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
|
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
|
||||||
- At boot, `tools.workspace_root` is the authoritative tool jail and project-observation root. A configured `tools.working_dir` may only remain distinct when it is contained by that root; an outside value is clamped to `workspace_root`. Project memory and repo-map indexing are also rebound to `workspace_root`, so a stale `[project].root` cannot inject files from outside the session workspace.
|
- At boot, `tools.workspace_root` is the authoritative default tool jail. Every session records its own resolved workspace binding; repo maps, project memory, profile/instruction snapshots, and git run branches use that binding and skip unbound sessions. `[project]` never supplies a workspace root.
|
||||||
|
|
||||||
### WebSocket protocol (`/ws`)
|
### WebSocket protocol (`/ws`)
|
||||||
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.correx.apps.server
|
package com.correx.apps.server
|
||||||
|
|
||||||
import com.correx.core.config.ProjectConfig
|
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
internal data class BootWorkspace(
|
internal data class BootWorkspace(
|
||||||
@@ -28,8 +27,3 @@ internal fun resolveBootWorkspace(
|
|||||||
workingDirWasClamped = !workingDirIsContained,
|
workingDirWasClamped = !workingDirIsContained,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep repo-map observation and L3 project memory inside the authoritative boot workspace. */
|
|
||||||
internal fun ProjectConfig.boundToWorkspace(workspaceRoot: Path): ProjectConfig = copy(
|
|
||||||
root = workspaceRoot.toAbsolutePath().normalize().toString(),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ fun main() {
|
|||||||
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
|
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
|
||||||
if (cfg.project.enabled) {
|
if (cfg.project.enabled) {
|
||||||
com.correx.apps.server.memory.ProjectMemoryService(
|
com.correx.apps.server.memory.ProjectMemoryService(
|
||||||
config = cfg.project.boundToWorkspace(workspaceRoot),
|
config = cfg.project,
|
||||||
embedder = embedder,
|
embedder = embedder,
|
||||||
l3MemoryStore = l3MemoryStore,
|
l3MemoryStore = l3MemoryStore,
|
||||||
journalRepository = decisionJournalRepository,
|
journalRepository = decisionJournalRepository,
|
||||||
|
|||||||
@@ -278,7 +278,8 @@ class ServerModule(
|
|||||||
event: ArtifactCreatedEvent,
|
event: ArtifactCreatedEvent,
|
||||||
): PossibleContradictionFlaggedEvent? {
|
): PossibleContradictionFlaggedEvent? {
|
||||||
val decisionText = resolveArchitectDecisionText(event) ?: return null
|
val decisionText = resolveArchitectDecisionText(event) ?: return null
|
||||||
val flag = checker.check(event.sessionId, event.stageId, decisionText) ?: return null
|
val workspaceRoot = sessionWorkspaceRoot(event.sessionId) ?: return null
|
||||||
|
val flag = checker.check(event.sessionId, event.stageId, decisionText, workspaceRoot) ?: return null
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
NewEvent(
|
NewEvent(
|
||||||
metadata = EventMetadata(
|
metadata = EventMetadata(
|
||||||
@@ -379,14 +380,15 @@ class ServerModule(
|
|||||||
withSessionContext(sessionId) {
|
withSessionContext(sessionId) {
|
||||||
// Record the repo map + seed prior-session memory before the run so stages
|
// Record the repo map + seed prior-session memory before the run so stages
|
||||||
// see both in context.
|
// see both in context.
|
||||||
|
sessionWorkspaceRoot(sessionId)?.let { root ->
|
||||||
projectMemory?.let { pm ->
|
projectMemory?.let { pm ->
|
||||||
val root = sessionWorkspaceRoot(sessionId)
|
|
||||||
runCatching {
|
runCatching {
|
||||||
pm.observeAndRecord(sessionId, root)
|
pm.observeAndRecord(sessionId, root)
|
||||||
pm.indexAndRecord(sessionId, root)
|
pm.indexAndRecord(sessionId, root)
|
||||||
pm.retrieveAndSeed(sessionId, root)
|
pm.retrieveAndSeed(sessionId, root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Bind operator profile snapshot as an event so replay reads the recorded
|
// Bind operator profile snapshot as an event so replay reads the recorded
|
||||||
// fact rather than re-reading the live file (invariants #8/#9).
|
// fact rather than re-reading the live file (invariants #8/#9).
|
||||||
operatorProfile?.let { profile ->
|
operatorProfile?.let { profile ->
|
||||||
@@ -417,7 +419,9 @@ class ServerModule(
|
|||||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||||
freestyleHandoff(sessionId, graph, result)
|
freestyleHandoff(sessionId, graph, result)
|
||||||
// Distil this run's decisions into durable project memory on completion.
|
// Distil this run's decisions into durable project memory on completion.
|
||||||
projectMemory?.let { pm -> pm.persist(sessionId, sessionWorkspaceRoot(sessionId)) }
|
sessionWorkspaceRoot(sessionId)?.let { root ->
|
||||||
|
projectMemory?.let { pm -> pm.persist(sessionId, root) }
|
||||||
|
}
|
||||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||||
operatorProfile?.let { profile ->
|
operatorProfile?.let { profile ->
|
||||||
profileAdaptationService?.let { svc ->
|
profileAdaptationService?.let { svc ->
|
||||||
@@ -426,7 +430,7 @@ class ServerModule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val workspaceRoot = sessionConfig.workspace?.workspaceRoot
|
val workspaceRoot = sessionWorkspaceRoot(sessionId)?.let(java.nio.file.Path::of)
|
||||||
if (gitRunBranchTransport != null && workspaceRoot != null) {
|
if (gitRunBranchTransport != null && workspaceRoot != null) {
|
||||||
gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() }
|
gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() }
|
||||||
} else {
|
} else {
|
||||||
@@ -501,19 +505,15 @@ class ServerModule(
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* The session's bound workspace root — the same one the tool jail and [SessionWorkspaceBoundEvent]
|
* The session's bound workspace root — the same one the tool jail and [SessionWorkspaceBoundEvent]
|
||||||
* use. The repo-map/index/L3-memory pipeline MUST key off this, not [ProjectMemoryService.repoRoot]
|
* use. Repo-scoped work must skip unbound sessions: a server cwd is not a session fact and
|
||||||
* (a session-independent config/cwd default): when they diverge the repo map is computed for a
|
* cannot safely stand in for the recorded workspace binding.
|
||||||
* different tree than the session operates in, so grounding "proves" real paths absent
|
|
||||||
* (session 5fe538f5, 2026-07-19). Falls back to the server default only when unbound.
|
|
||||||
*/
|
*/
|
||||||
private fun sessionWorkspaceRoot(sessionId: SessionId): String =
|
private fun sessionWorkspaceRoot(sessionId: SessionId): String? =
|
||||||
runCatching { sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot }
|
runCatching { sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot }
|
||||||
.getOrNull() ?: projectMemory?.repoRoot() ?: "."
|
.getOrNull()
|
||||||
|
|
||||||
suspend fun bindProjectProfile(sessionId: SessionId) {
|
suspend fun bindProjectProfile(sessionId: SessionId) {
|
||||||
val workspaceRoot = runCatching {
|
val workspaceRoot = sessionWorkspaceRoot(sessionId) ?: return
|
||||||
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
|
|
||||||
}.getOrNull() ?: projectMemory?.repoRoot() ?: return
|
|
||||||
val projectProfile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
|
val projectProfile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
|
||||||
if (projectProfile.isEmpty()) return
|
if (projectProfile.isEmpty()) return
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
@@ -543,9 +543,7 @@ class ServerModule(
|
|||||||
* live file (invariants #8/#9). Mirrors [bindProjectProfile].
|
* live file (invariants #8/#9). Mirrors [bindProjectProfile].
|
||||||
*/
|
*/
|
||||||
suspend fun bindAgentInstructions(sessionId: SessionId) {
|
suspend fun bindAgentInstructions(sessionId: SessionId) {
|
||||||
val workspaceRoot = runCatching {
|
val workspaceRoot = sessionWorkspaceRoot(sessionId) ?: return
|
||||||
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
|
|
||||||
}.getOrNull() ?: projectMemory?.repoRoot() ?: return
|
|
||||||
val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) }
|
val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) }
|
||||||
if (instructions.isEmpty()) return
|
if (instructions.isEmpty()) return
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
|
|||||||
+8
-8
@@ -16,18 +16,16 @@ import com.correx.core.talkie.l3.L3Query
|
|||||||
* emits the flag without ever halting or failing the stage.
|
* emits the flag without ever halting or failing the stage.
|
||||||
*
|
*
|
||||||
* Namespace convention: distilled decision-journal lines are persisted into L3 by
|
* Namespace convention: distilled decision-journal lines are persisted into L3 by
|
||||||
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
|
* [ProjectMemoryService] under `turnId = "project:<workspaceRoot>"`. The exact tag is derived
|
||||||
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
|
* from the session's recorded workspace binding so a decision can never cross workspace boundaries.
|
||||||
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by
|
* Hits are also constrained to PRIOR sessions (`entry.sessionId != sessionId`) so the architect
|
||||||
* [L3RepoKnowledgeRetriever]'s versioned `"repomap:v2:<repoRoot>:"` filter. Hits are also constrained to PRIOR
|
* never flags its own in-flight run.
|
||||||
* sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
|
|
||||||
*/
|
*/
|
||||||
class ArchitectContradictionChecker(
|
class ArchitectContradictionChecker(
|
||||||
private val embedder: Embedder,
|
private val embedder: Embedder,
|
||||||
private val l3MemoryStore: L3MemoryStore,
|
private val l3MemoryStore: L3MemoryStore,
|
||||||
private val k: Int = DEFAULT_K,
|
private val k: Int = DEFAULT_K,
|
||||||
private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD,
|
private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD,
|
||||||
private val decisionNamespacePrefix: String = DEFAULT_DECISION_NAMESPACE_PREFIX,
|
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when
|
* @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when
|
||||||
@@ -37,11 +35,12 @@ class ArchitectContradictionChecker(
|
|||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
decisionText: String,
|
decisionText: String,
|
||||||
|
workspaceRoot: String,
|
||||||
): PossibleContradictionFlaggedEvent? {
|
): PossibleContradictionFlaggedEvent? {
|
||||||
if (decisionText.isBlank()) return null
|
if (decisionText.isBlank()) return null
|
||||||
val vector = embedder.embed(decisionText)
|
val vector = embedder.embed(decisionText)
|
||||||
val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
|
val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
|
||||||
.filter { it.entry.turnId.startsWith(decisionNamespacePrefix) }
|
.filter { it.entry.turnId == projectMemoryTag(workspaceRoot) }
|
||||||
.filter { it.entry.sessionId != sessionId }
|
.filter { it.entry.sessionId != sessionId }
|
||||||
.filter { it.score >= scoreThreshold }
|
.filter { it.score >= scoreThreshold }
|
||||||
.take(k)
|
.take(k)
|
||||||
@@ -64,7 +63,8 @@ class ArchitectContradictionChecker(
|
|||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_K = 5
|
const val DEFAULT_K = 5
|
||||||
const val DEFAULT_SCORE_THRESHOLD = 0.75
|
const val DEFAULT_SCORE_THRESHOLD = 0.75
|
||||||
const val DEFAULT_DECISION_NAMESPACE_PREFIX = "project:"
|
|
||||||
private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4
|
private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4
|
||||||
|
|
||||||
|
fun projectMemoryTag(workspaceRoot: String): String = "project:$workspaceRoot"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ class ProjectMemoryService(
|
|||||||
|
|
||||||
private fun tag(repoRoot: String) = "project:$repoRoot"
|
private fun tag(repoRoot: String) = "project:$repoRoot"
|
||||||
|
|
||||||
/** Resolved repo-root key: configured [ProjectConfig.root], else the working dir. */
|
|
||||||
fun repoRoot(): String = config.root.ifBlank { System.getProperty("user.dir") ?: "." }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walk [repoRoot] and record the ranked file/symbol map as a [RepoMapComputedEvent] once
|
* Walk [repoRoot] and record the ranked file/symbol map as a [RepoMapComputedEvent] once
|
||||||
* per session. The full map is recorded (the log); only a top-K slice is injected into
|
* per session. The full map is recorded (the log); only a top-K slice is injected into
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.correx.apps.server
|
package com.correx.apps.server
|
||||||
|
|
||||||
import com.correx.core.config.ProjectConfig
|
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
@@ -47,15 +46,4 @@ class BootWorkspaceTest {
|
|||||||
assertFalse(resolved.workingDirWasClamped)
|
assertFalse(resolved.workingDirWasClamped)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `project memory root follows the authoritative workspace root`() {
|
|
||||||
val configured = ProjectConfig(
|
|
||||||
enabled = true,
|
|
||||||
root = "/home/user/repo",
|
|
||||||
)
|
|
||||||
|
|
||||||
val resolved = configured.boundToWorkspace(Path.of("/tmp/audition/../audition"))
|
|
||||||
|
|
||||||
assertEquals("/tmp/audition", resolved.root)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -52,7 +52,7 @@ class ArchitectContradictionCheckerTest {
|
|||||||
)
|
)
|
||||||
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
||||||
|
|
||||||
val flag = checker.check(newSession, stageId, "Use Postgres for the event store.")
|
val flag = checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo")
|
||||||
|
|
||||||
assertTrue(flag != null, "expected a flag")
|
assertTrue(flag != null, "expected a flag")
|
||||||
assertEquals(newSession, flag!!.sessionId)
|
assertEquals(newSession, flag!!.sessionId)
|
||||||
@@ -69,7 +69,7 @@ class ArchitectContradictionCheckerTest {
|
|||||||
fun `returns null when there are no hits`() = runBlocking {
|
fun `returns null when there are no hits`() = runBlocking {
|
||||||
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList()))
|
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList()))
|
||||||
|
|
||||||
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
|
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -79,7 +79,7 @@ class ArchitectContradictionCheckerTest {
|
|||||||
)
|
)
|
||||||
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75)
|
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75)
|
||||||
|
|
||||||
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
|
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -92,7 +92,17 @@ class ArchitectContradictionCheckerTest {
|
|||||||
)
|
)
|
||||||
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
||||||
|
|
||||||
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
|
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filters out decisions from another workspace`() = runBlocking {
|
||||||
|
val store = CannedL3MemoryStore(
|
||||||
|
listOf(hit("Other workspace decision.", score = 0.95f, turnId = "project:/other-repo")),
|
||||||
|
)
|
||||||
|
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
||||||
|
|
||||||
|
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -102,6 +112,6 @@ class ArchitectContradictionCheckerTest {
|
|||||||
)
|
)
|
||||||
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
||||||
|
|
||||||
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
|
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -44,7 +44,7 @@ class ProjectMemoryServiceObservationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `probe success emits WorkspaceStateObservedEvent`(): Unit = runBlocking {
|
fun `probe success emits WorkspaceStateObservedEvent`(): Unit = runBlocking {
|
||||||
val es = InMemoryEventStore()
|
val es = InMemoryEventStore()
|
||||||
val config = ProjectConfig(enabled = true, root = "/repo")
|
val config = ProjectConfig(enabled = true)
|
||||||
val sessionId = SessionId("session-obs-1")
|
val sessionId = SessionId("session-obs-1")
|
||||||
|
|
||||||
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
|
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
|
||||||
@@ -63,7 +63,7 @@ class ProjectMemoryServiceObservationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `probe null emits no event`(): Unit = runBlocking {
|
fun `probe null emits no event`(): Unit = runBlocking {
|
||||||
val es = InMemoryEventStore()
|
val es = InMemoryEventStore()
|
||||||
val config = ProjectConfig(enabled = true, root = "/repo")
|
val config = ProjectConfig(enabled = true)
|
||||||
val sessionId = SessionId("session-obs-2")
|
val sessionId = SessionId("session-obs-2")
|
||||||
|
|
||||||
service(config, es, null).observeAndRecord(sessionId, "/repo")
|
service(config, es, null).observeAndRecord(sessionId, "/repo")
|
||||||
@@ -76,7 +76,7 @@ class ProjectMemoryServiceObservationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `disabled config skips observation`(): Unit = runBlocking {
|
fun `disabled config skips observation`(): Unit = runBlocking {
|
||||||
val es = InMemoryEventStore()
|
val es = InMemoryEventStore()
|
||||||
val config = ProjectConfig(enabled = false, root = "/repo")
|
val config = ProjectConfig(enabled = false)
|
||||||
val sessionId = SessionId("session-obs-3")
|
val sessionId = SessionId("session-obs-3")
|
||||||
|
|
||||||
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
|
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
|
||||||
|
|||||||
+3
-4
@@ -40,9 +40,8 @@ class ProjectMemoryServiceReuseTest {
|
|||||||
l3: InMemoryL3MemoryStore,
|
l3: InMemoryL3MemoryStore,
|
||||||
indexer: CountingIndexer,
|
indexer: CountingIndexer,
|
||||||
probe: WorkspaceStateProbe,
|
probe: WorkspaceStateProbe,
|
||||||
root: String = "/repo",
|
|
||||||
) = ProjectMemoryService(
|
) = ProjectMemoryService(
|
||||||
config = ProjectConfig(enabled = true, root = root),
|
config = ProjectConfig(enabled = true),
|
||||||
embedder = ConstantEmbedderReuse(),
|
embedder = ConstantEmbedderReuse(),
|
||||||
l3MemoryStore = l3,
|
l3MemoryStore = l3,
|
||||||
journalRepository = DefaultDecisionJournalRepository(
|
journalRepository = DefaultDecisionJournalRepository(
|
||||||
@@ -136,9 +135,9 @@ class ProjectMemoryServiceReuseTest {
|
|||||||
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
|
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
|
||||||
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
|
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
|
||||||
|
|
||||||
service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo")
|
service(es, l3, CountingIndexer(repoEntries), probe)
|
||||||
.indexAndRecord(SessionId("session-collide-repo"), "/repo")
|
.indexAndRecord(SessionId("session-collide-repo"), "/repo")
|
||||||
service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2")
|
service(es, l3, CountingIndexer(repo2Entries), probe)
|
||||||
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
|
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
|
||||||
|
|
||||||
// existsByTurnIdPrefix with the delimiter must not match the other root.
|
// existsByTurnIdPrefix with the delimiter must not match the other root.
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ class ProjectMemoryServiceTest {
|
|||||||
fun `decisions persisted in one session are retrieved and seeded in the next`(): Unit = runBlocking {
|
fun `decisions persisted in one session are retrieved and seeded in the next`(): Unit = runBlocking {
|
||||||
val eventStore = InMemoryEventStore()
|
val eventStore = InMemoryEventStore()
|
||||||
val l3 = InMemoryL3MemoryStore()
|
val l3 = InMemoryL3MemoryStore()
|
||||||
val config = ProjectConfig(enabled = true, root = "/repo", memoryK = 5)
|
val config = ProjectConfig(enabled = true, memoryK = 5)
|
||||||
|
|
||||||
val sessionA = SessionId("A")
|
val sessionA = SessionId("A")
|
||||||
store(sessionA, SteeringNoteAddedEvent(sessionA, "use jwt for auth"), eventStore)
|
store(sessionA, SteeringNoteAddedEvent(sessionA, "use jwt for auth"), eventStore)
|
||||||
@@ -85,7 +85,7 @@ class ProjectMemoryServiceTest {
|
|||||||
fun `disabled project memory is a no-op`(): Unit = runBlocking {
|
fun `disabled project memory is a no-op`(): Unit = runBlocking {
|
||||||
val eventStore = InMemoryEventStore()
|
val eventStore = InMemoryEventStore()
|
||||||
val l3 = InMemoryL3MemoryStore()
|
val l3 = InMemoryL3MemoryStore()
|
||||||
val config = ProjectConfig(enabled = false, root = "/repo")
|
val config = ProjectConfig(enabled = false)
|
||||||
|
|
||||||
val sessionA = SessionId("A")
|
val sessionA = SessionId("A")
|
||||||
store(sessionA, SteeringNoteAddedEvent(sessionA, "secret"), eventStore)
|
store(sessionA, SteeringNoteAddedEvent(sessionA, "secret"), eventStore)
|
||||||
|
|||||||
@@ -679,7 +679,6 @@ object ConfigLoader {
|
|||||||
val projectSection = sections["project"] ?: emptyMap()
|
val projectSection = sections["project"] ?: emptyMap()
|
||||||
val project = ProjectConfig(
|
val project = ProjectConfig(
|
||||||
enabled = asBoolean(projectSection["enabled"], false),
|
enabled = asBoolean(projectSection["enabled"], false),
|
||||||
root = asString(projectSection["root"], ""),
|
|
||||||
memoryK = asInt(projectSection["memory_k"], DEFAULT_PROJECT_MEMORY_K),
|
memoryK = asInt(projectSection["memory_k"], DEFAULT_PROJECT_MEMORY_K),
|
||||||
maxDepth = asInt(projectSection["max_depth"], DEFAULT_PROJECT_MAX_DEPTH),
|
maxDepth = asInt(projectSection["max_depth"], DEFAULT_PROJECT_MAX_DEPTH),
|
||||||
ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES },
|
ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES },
|
||||||
|
|||||||
@@ -146,12 +146,11 @@ data class PersonalizationConfig(
|
|||||||
/**
|
/**
|
||||||
* Project-scoped, cross-session memory. When [enabled], the decision journal is distilled
|
* Project-scoped, cross-session memory. When [enabled], the decision journal is distilled
|
||||||
* to durable per-repo memory at session end and retrieved (top-[memoryK]) at session start.
|
* to durable per-repo memory at session end and retrieved (top-[memoryK]) at session start.
|
||||||
* [root] is the repo root key; empty means the current working directory.
|
* The repository key is always the session's recorded bound workspace, never configuration.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ProjectConfig(
|
data class ProjectConfig(
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val root: String = "",
|
|
||||||
val memoryK: Int = 5,
|
val memoryK: Int = 5,
|
||||||
val maxDepth: Int = 4,
|
val maxDepth: Int = 4,
|
||||||
val ignoreGlobs: List<String> = DEFAULT_IGNORES,
|
val ignoreGlobs: List<String> = DEFAULT_IGNORES,
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ object CorrexConfigWriter {
|
|||||||
|
|
||||||
b.section("project")
|
b.section("project")
|
||||||
b.kv("enabled", cfg.project.enabled)
|
b.kv("enabled", cfg.project.enabled)
|
||||||
b.kv("root", str(cfg.project.root))
|
|
||||||
b.kv("memory_k", cfg.project.memoryK)
|
b.kv("memory_k", cfg.project.memoryK)
|
||||||
b.kv("max_depth", cfg.project.maxDepth)
|
b.kv("max_depth", cfg.project.maxDepth)
|
||||||
b.kv("inject_top_k", cfg.project.injectTopK)
|
b.kv("inject_top_k", cfg.project.injectTopK)
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ class ConfigLoaderTest {
|
|||||||
val toml = """
|
val toml = """
|
||||||
[project]
|
[project]
|
||||||
enabled = true
|
enabled = true
|
||||||
root = "/home/me/repo"
|
|
||||||
memory_k = 8
|
memory_k = 8
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
@@ -199,7 +198,6 @@ class ConfigLoaderTest {
|
|||||||
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||||
|
|
||||||
assertEquals(true, result.project.enabled)
|
assertEquals(true, result.project.enabled)
|
||||||
assertEquals("/home/me/repo", result.project.root)
|
|
||||||
assertEquals(8, result.project.memoryK)
|
assertEquals(8, result.project.memoryK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +214,6 @@ class ConfigLoaderTest {
|
|||||||
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||||
|
|
||||||
assertEquals(false, result.project.enabled)
|
assertEquals(false, result.project.enabled)
|
||||||
assertEquals("", result.project.root)
|
|
||||||
assertEquals(5, result.project.memoryK)
|
assertEquals(5, result.project.memoryK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class CorrexConfigWriterTest {
|
|||||||
narration = NarrationSettings(temperature = 0.1, topP = 0.95, maxTokens = 256, maxPerRun = 3),
|
narration = NarrationSettings(temperature = 0.1, topP = 0.95, maxTokens = 256, maxPerRun = 3),
|
||||||
),
|
),
|
||||||
personalization = PersonalizationConfig(enabled = true, learn = true),
|
personalization = PersonalizationConfig(enabled = true, learn = true),
|
||||||
project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40),
|
project = ProjectConfig(enabled = true, memoryK = 8, maxDepth = 6, injectTopK = 40),
|
||||||
git = GitConfig(enabled = true, remote = "gitea", baseBranch = "develop", author = "Correx <bot@example.test>"),
|
git = GitConfig(enabled = true, remote = "gitea", baseBranch = "develop", author = "Correx <bot@example.test>"),
|
||||||
modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
|
modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
|
||||||
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
|
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class RepoMapReuseIntegrationTest {
|
|||||||
embedder: Embedder,
|
embedder: Embedder,
|
||||||
probe: FakeWorkspaceStateProbe,
|
probe: FakeWorkspaceStateProbe,
|
||||||
) = ProjectMemoryService(
|
) = ProjectMemoryService(
|
||||||
config = ProjectConfig(enabled = true, root = repoRoot),
|
config = ProjectConfig(enabled = true),
|
||||||
embedder = embedder,
|
embedder = embedder,
|
||||||
l3MemoryStore = l3,
|
l3MemoryStore = l3,
|
||||||
journalRepository = DefaultDecisionJournalRepository(
|
journalRepository = DefaultDecisionJournalRepository(
|
||||||
|
|||||||
Reference in New Issue
Block a user