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:
@@ -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) ->
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user