fix(server,kernel,events): harden artifact read + restore workspace on reopen + cleanups

Follow-ups spotted while fixing the artifact-content bug:

- #1 robustness: listArtifacts resolves content via runCatching, degrading to null
  instead of failing the whole listing on one bad CAS read (resolveContent helper)
- #2 eviction visibility: WARN when a referenced artifact hash resolves to null
  (most likely evicted/compacted while still referenced by the event log)
- #3 ordering trap: document on ArtifactCreated/ArtifactContentStored that 'Created'
  fires at validation, AFTER ContentStored at inference — the latent fold hazard
- #4 reopen gap: SessionSnapshot now carries workspaceRoot (derived from
  SessionWorkspaceBound), so a reopened session restores its workspace label
- #5 orchestrator headroom: isBackEdge externalised to a top-level function so
  DefaultSessionOrchestrator drops below the TooManyFunctions threshold (was at it)

#6 (orphaned empty F-010-era artifacts) needs no code change — empty content is
already normalised to null ('(no content stored)'); emission is prevented going
forward by the shipped F-010/F-018/F-020 fixes.
This commit is contained in:
2026-06-10 12:58:28 +04:00
parent 5f7b6f1f18
commit cc6b402a23
6 changed files with 51 additions and 9 deletions
@@ -23,6 +23,7 @@ import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
@@ -141,6 +142,9 @@ class SessionEventBridge(
// Restore the output panel on reopen (otherwise the snapshot carries no output).
val lastOutput = resolveLastOutput(events)
val workspaceRoot = events.lastOrNull { it.payload is SessionWorkspaceBoundEvent }
?.let { (it.payload as SessionWorkspaceBoundEvent).workspaceRoot }
.orEmpty()
// Re-register pending approvals so the ApprovalCoordinator can route responses
// from clients that connected after the ApprovalRequestedEvent was emitted.
@@ -163,6 +167,7 @@ class SessionEventBridge(
recentEvents = recentEvents,
lastOutput = lastOutput,
lastResponse = lastOutput,
workspaceRoot = workspaceRoot,
lastSequence = lastGlobal,
lastSessionSequence = lastSession,
))
@@ -123,6 +123,9 @@ sealed interface ServerMessage {
// output panel instead of showing blank until new activity arrives.
val lastOutput: String = "",
val lastResponse: String = "",
// The session's bound workspace cwd, so a reopened session restores its workspace label
// (otherwise blank until a new workspace_bound, which only fires on a fresh start).
val workspaceRoot: String = "",
val lastSequence: Long,
val lastSessionSequence: Long,
override val sequence: Long? = null,
@@ -16,6 +16,9 @@ import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger("StreamQueries")
/**
* Read-only / snapshot queries served over the global stream: artifact listings and config
@@ -33,20 +36,37 @@ class StreamQueries(private val module: ServerModule) {
suspend fun listArtifacts(sessionId: SessionId): ServerMessage.ArtifactList {
val events = withContext(Dispatchers.IO) { module.eventStore.read(sessionId) }
val artifacts = planArtifacts(events).map { plan ->
val content = plan.contentHash?.let {
withContext(Dispatchers.IO) { module.artifactStore.get(it) }?.toString(Charsets.UTF_8)
}
ArtifactSummaryDto(
artifactId = plan.artifactId,
stageId = plan.stageId,
schemaVersion = plan.schemaVersion,
phase = plan.phase,
content = content?.takeIf { it.isNotBlank() },
content = resolveContent(plan.contentHash),
)
}
return ServerMessage.ArtifactList(sessionId = sessionId, artifacts = artifacts)
}
/**
* Resolves an artifact's UTF-8 content from CAS, degrading to null instead of throwing. A
* referenced hash that resolves to null is logged WARN — most likely the bytes were evicted by
* the CAS [com.correx.infrastructure.artifactscas.evict.Evictor] while the event log still
* references them, which would otherwise blank a single artifact with no signal.
*/
private suspend fun resolveContent(contentHash: ArtifactId?): String? {
val hash = contentHash ?: return null
val bytes = runCatching { withContext(Dispatchers.IO) { module.artifactStore.get(hash) } }
.getOrElse {
log.warn("artifact content read failed for hash={}: {}", hash.value, it.message)
null
}
if (bytes == null) {
log.warn("artifact content missing for referenced hash={} (evicted/compacted?)", hash.value)
return null
}
return bytes.toString(Charsets.UTF_8).takeIf { it.isNotBlank() }
}
/** Current editable config as a snapshot frame. [error] / [restartRequired] are set by updates. */
fun configSnapshot(error: String?, restartRequired: List<String>): ServerMessage.ConfigSnapshot {
val fields = module.configService.snapshot().map { (field, value) ->
+1
View File
@@ -396,6 +396,7 @@ func (m *Model) onSnapshot(msg protocol.ServerMessage) {
}
sess.LastOutput = msg.LastOutput
sess.LastResponse = msg.LastResponse
sess.WorkspaceRoot = msg.WorkspaceRoot
for _, e := range msg.RecentEvents {
sess.Events = append(sess.Events, EventEntry{formatTime(e.Timestamp), e.Type, e.Detail})
}
@@ -22,6 +22,13 @@ data class ArtifactValidatedEvent(
val stageId: StageId,
) : EventPayload
/**
* ⚠️ ORDERING TRAP: despite the name, this is emitted at the *validation* phase — AFTER
* [ArtifactContentStoredEvent] (which fires at inference time). Any fold that derives per-artifact
* state must NOT assume `ArtifactCreated` is the first event for an [artifactId]; seed accumulators
* from whichever artifact event arrives first (see StreamQueries.planArtifacts, which was bitten by
* exactly this — the content hash arrives one event earlier).
*/
@Serializable
@SerialName("ArtifactCreated")
data class ArtifactCreatedEvent(
@@ -31,6 +38,10 @@ data class ArtifactCreatedEvent(
val schemaVersion: Int,
) : EventPayload
/**
* Records the slot→CAS-hash mapping for a produced artifact. Emitted at inference time, so it
* precedes [ArtifactCreatedEvent] for the same [artifactId] (see the ordering note there).
*/
@Serializable
@SerialName("ArtifactContentStored")
data class ArtifactContentStoredEvent(
@@ -14,6 +14,7 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.types.ApprovalDecisionId
@@ -274,7 +275,7 @@ class DefaultSessionOrchestrator(
// run, e.g. reviewer→implementer) increments a per-cycle counter recorded as an
// event, so the guard is replay-deterministic. Exceeding the cap escalates to a
// terminal failure instead of looping forever.
if (isBackEdge(ctx, nextStageId)) {
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: DEFAULT_MAX_REFINEMENT
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
@@ -391,10 +392,6 @@ class DefaultSessionOrchestrator(
}
}
private fun isBackEdge(ctx: EnrichedExecutionContext, target: StageId): Boolean =
repositories.eventStore.read(ctx.sessionId)
.any { (it.payload as? TransitionExecutedEvent)?.to == target }
private fun ExecutionContext.enrich() = EnrichedExecutionContext(
graph, sessionId, stageCount, currentStageId, config,
session = repositories.sessionRepository.getSession(sessionId),
@@ -402,6 +399,11 @@ class DefaultSessionOrchestrator(
)
}
// A back-edge re-enters a stage already transitioned into this run (e.g. reviewer→implementer).
// Top-level (not a member) to keep the orchestrator off the TooManyFunctions threshold.
private fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
events.any { (it.payload as? TransitionExecutedEvent)?.to == target }
private sealed class StepResult {
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
data class Terminal(val result: WorkflowResult) : StepResult()