Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ sealed interface OpenCodeEvent {

data class SessionIdle(val sessionId: String) : OpenCodeEvent

/** A new session appeared. Its [OpenCodeSession.parentId] names the session that spawned it. */
data class SessionCreated(val session: OpenCodeSession) : OpenCodeEvent

/** A session's metadata changed; carries the same payload as [SessionCreated]. */
data class SessionUpdated(val session: OpenCodeSession) : OpenCodeEvent

/** Replacement for the deprecated `session.idle`: status is `idle`, `busy` or `retry`. */
data class SessionStatusChanged(val sessionId: String, val status: String) : OpenCodeEvent

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ class OpenCodeEventParser(
requestId = properties["requestID"]!!.jsonPrimitive.content,
)
"session.idle" -> OpenCodeEvent.SessionIdle(properties["sessionID"]!!.jsonPrimitive.content)
"session.created" -> {
val session =
json.decodeFromJsonElement(
OpenCodeSession.serializer(),
properties["info"]!!.jsonObject,
)
OpenCodeEvent.SessionCreated(session)
}
Comment on lines +91 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
session.createdsession.updated の分岐は properties["info"]OpenCodeSession にデコードするロジックが完全に重複しています。private fun parseSession(properties: JsonObject) = json.decodeFromJsonElement(OpenCodeSession.serializer(), properties["info"]!!.jsonObject) のようなヘルパーを抽出し、各分岐を OpenCodeEvent.SessionCreated(parseSession(properties)) / OpenCodeEvent.SessionUpdated(parseSession(properties)) とすると冗長さが解消され、今後デコード処理を変更する際の差分も1箇所に集約できます。

"session.updated" -> {
val session =
json.decodeFromJsonElement(
OpenCodeSession.serializer(),
properties["info"]!!.jsonObject,
)
OpenCodeEvent.SessionUpdated(session)
}
"session.status" ->
OpenCodeEvent.SessionStatusChanged(
sessionId = properties["sessionID"]!!.jsonPrimitive.content,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ class RuntimeActivityRepository(
private val mutableResolvedPermissions = MutableSharedFlow<String>(extraBufferCapacity = 64)
val resolvedPermissions: SharedFlow<String> = mutableResolvedPermissions.asSharedFlow()

/**
* Child session id to the session that spawned it, learned from `session.created` /
* `session.updated` events and resolved through the runtime API when the stream missed the
* creation (app restart mid-run, reconnected stream). A key present with a null value means
* the session is known to have no parent, so it is resolved only once.
*
* Events arrive on the stream collector while [markSessionRunning] arrives from the UI, so
* both maps are guarded by [parentLock].
*/
private val parentIds = mutableMapOf<String, String?>()
private val parentLock = Any()
Comment on lines +93 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · low]
parentIdsruntimeIdleSessionIds は一度追加されたエントリが削除される経路がなく、セッションが増える・対象ランタイムが切り替わるたびに単調に増加し続けます。markSessionRunningruntimeIdleSessionIds を除去するのは一部の経路のみで、parentIds に至っては削除処理が一切ありません。長時間の利用でメモリが蓄積するため、セッション削除時やランタイム切替時に不要エントリを掃除する仕組みを検討してください。


/**
* Sessions the runtime itself reported as no longer running. Subagent events must not
* resurrect a parent whose own turn already ended (a background subagent outlives it), but
* must resurrect one that was only settled locally by [markSessionFinished] and is in fact
* still blocked on the subagent.
*/
private val runtimeIdleSessionIds = mutableSetOf<String>()

init {
scope.launch {
registry.selected.collectLatest selected@{ target ->
Expand Down Expand Up @@ -163,6 +183,7 @@ class RuntimeActivityRepository(
*/
fun markSessionRunning(sessionId: String) {
if (sessionId.isBlank()) return
synchronized(parentLock) { runtimeIdleSessionIds.remove(sessionId) }
mutableState.update { current ->
current.copy(
activeSessionIds = current.activeSessionIds + sessionId,
Expand Down Expand Up @@ -204,35 +225,22 @@ class RuntimeActivityRepository(
) {
when (event) {
OpenCodeEvent.ServerConnected -> appendLog(messages.eventConnectedTitle, messages.eventConnectedDetail)
is OpenCodeEvent.SessionCreated -> rememberParent(event.session.id, event.session.parentId)
is OpenCodeEvent.SessionUpdated -> rememberParent(event.session.id, event.session.parentId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · low]
SessionUpdated は既存のキャッシュを無条件に上書きします。OpenCodeSession.parentId はデフォルト値 null のため、session.updatedinfoparentID が含まれない部分更新だった場合、SessionCreated で得られていた正しい親情報が null で上書きされて失われます。親関係は原則変化しないため、SessionUpdated では非 null のときだけ記録するのが安全です(SessionCreated での null 記録は「親無し確定」なのでそのまま)。

Suggestion:

Suggested change
is OpenCodeEvent.SessionUpdated -> rememberParent(event.session.id, event.session.parentId)
is OpenCodeEvent.SessionUpdated -> event.session.parentId?.let { rememberParent(event.session.id, it) }

is OpenCodeEvent.MessagePartUpdated -> {
val sessionId = event.part.sessionId ?: return
mutableState.update { current ->
if (sessionId in current.settledSessionIds || sessionId in current.completedSessionIds) {
current
} else {
current.copy(activeSessionIds = current.activeSessionIds + sessionId)
}
}
activateSession(target, sessionId)
when (event.part.type) {
"tool", "tool-invocation" -> appendLog(messages.eventTool, event.part.tool, sessionId)
"reasoning" -> appendLog(messages.eventReasoning, null, sessionId)
}
}
is OpenCodeEvent.MessagePartDelta -> {
mutableState.update { current ->
if (event.sessionId in current.settledSessionIds || event.sessionId in current.completedSessionIds) {
current
} else {
current.copy(activeSessionIds = current.activeSessionIds + event.sessionId)
}
}
}
is OpenCodeEvent.MessagePartDelta -> activateSession(target, event.sessionId)
is OpenCodeEvent.PermissionAsked -> {
// A live request proves the session is waiting, settled or not.
activateSession(target, event.request.sessionId, force = true)
mutableState.update { current ->
current.copy(
permissions = current.permissions.filterNot { it.id == event.request.id } + event.request,
activeSessionIds = current.activeSessionIds + event.request.sessionId,
)
current.copy(permissions = current.permissions.filterNot { it.id == event.request.id } + event.request)
}
appendLog(messages.eventPermission, event.request.permission, event.request.sessionId)
onPermissionAsked?.invoke(
Expand All @@ -242,6 +250,7 @@ class RuntimeActivityRepository(
)
}
is OpenCodeEvent.SessionIdle -> {
markRuntimeIdle(event.sessionId)
mutableState.update { current ->
current.copy(
activeSessionIds = current.activeSessionIds - event.sessionId,
Expand All @@ -250,22 +259,11 @@ class RuntimeActivityRepository(
)
}
appendLog(messages.eventCompleted, null, event.sessionId)
val isSubagent =
runCatching { target.session(event.sessionId).parentId != null }
.getOrDefault(false)
if (!isSubagent) {
if (parentIdOf(target, event.sessionId) == null) {
onSessionIdle?.invoke(event.sessionId, sessionTitle(target, event.sessionId))
}
}
is OpenCodeEvent.MessageUpdated -> {
mutableState.update { current ->
if (event.info.sessionId in current.settledSessionIds || event.info.sessionId in current.completedSessionIds) {
current
} else {
current.copy(activeSessionIds = current.activeSessionIds + event.info.sessionId)
}
}
}
is OpenCodeEvent.MessageUpdated -> activateSession(target, event.info.sessionId)
is OpenCodeEvent.PermissionReplied -> {
mutableState.update { current ->
current.copy(permissions = current.permissions.filterNot { it.id == event.requestId })
Expand All @@ -274,6 +272,11 @@ class RuntimeActivityRepository(
onPermissionResolved?.invoke(event.requestId)
}
is OpenCodeEvent.SessionStatusChanged -> {
if (event.status == "idle") {
markRuntimeIdle(event.sessionId)
} else {
markRuntimeRunning(event.sessionId)
}
mutableState.update { current ->
current.copy(
activeSessionIds =
Expand All @@ -298,9 +301,13 @@ class RuntimeActivityRepository(
},
)
}
if (event.status != "idle") {
activateAncestors(target, event.sessionId)
}
}
is OpenCodeEvent.SessionError -> {
event.sessionId?.let { sessionId ->
markRuntimeIdle(sessionId)
mutableState.update { current ->
current.copy(
activeSessionIds = current.activeSessionIds - sessionId,
Expand All @@ -312,9 +319,7 @@ class RuntimeActivityRepository(
onSessionError?.invoke(event.sessionId, event.message)
}
is OpenCodeEvent.QuestionAsked -> {
mutableState.update { current ->
current.copy(activeSessionIds = current.activeSessionIds + event.request.sessionId)
}
activateSession(target, event.request.sessionId, force = true)
appendLog(messages.eventQuestion, event.request.questions.firstOrNull()?.question, event.request.sessionId)
onQuestionAsked?.invoke(
event.request,
Expand All @@ -325,6 +330,86 @@ class RuntimeActivityRepository(
}
}

/**
* Marks a session active unless it was settled, and reports the same for every ancestor.
*
* A session blocked on the task tool emits nothing while its subagent works, so the runtime
* events of the child are the only signal that the parent's turn is still in flight. Without
* forwarding them, a parent the user navigated away from (which [markSessionFinished] then
* settles) sat on the grey idle dot in the drawer for the subagent's entire run.
*/
private suspend fun activateSession(
target: RuntimeTarget,
sessionId: String,
force: Boolean = false,
) {
if (sessionId.isBlank()) return
mutableState.update { current ->
Comment on lines +341 to +347

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
activateSession はセッションを activeSessionIds に追加しますが、runtimeIdleSessionIds からは除去しません。markSessionRunningSessionStatusChanged(非idle) は markRuntimeRunning で除去しているのに対し、ストリームイベント経由の再活性化だけが除去されず非対称です。一度 idle 扱い(SessionIdle / SessionStatusChanged(idle) / SessionError)されたセッションがメッセージ等のイベントで再開した後、そのセッションを親に持つサブエージェントのイベントが来ると activateAncestorsisRuntimeIdle で即 break し、祖先チェーンが再活性化されません。冒頭で markSessionRunning 同様に idle フラグを除去してください。

Suggestion:

Suggested change
private suspend fun activateSession(
target: RuntimeTarget,
sessionId: String,
force: Boolean = false,
) {
if (sessionId.isBlank()) return
mutableState.update { current ->
private suspend fun activateSession(
target: RuntimeTarget,
sessionId: String,
force: Boolean = false,
) {
if (sessionId.isBlank()) return
synchronized(parentLock) { runtimeIdleSessionIds.remove(sessionId) }
mutableState.update { current ->

if (!force && (sessionId in current.settledSessionIds || sessionId in current.completedSessionIds)) {
current
} else {
current.copy(activeSessionIds = current.activeSessionIds + sessionId)
}
}
activateAncestors(target, sessionId)
}

/**
* Walks up the parent chain keeping every ancestor marked running. An ancestor the runtime
* already reported idle ends the walk: its turn no longer waits on this subagent chain (only
* experimental background subagents outlive their parent's turn).
*/
private suspend fun activateAncestors(
target: RuntimeTarget,
sessionId: String,
) {
var parentId = parentIdOf(target, sessionId)
while (parentId != null) {
if (isRuntimeIdle(parentId)) break
val ancestorId = parentId
Comment on lines +366 to +369

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
parentIds は runtime のイベント/API 由来のデータをキャッシュしており、万一親チェーンに循環(A→B→A のような不整合データ)が混入すると、isRuntimeIdle による break 条件が成立しない限りこの while ループが無限に回り続けます。ループ内では parentIdOf がネットワーク呼び出しを伴う可能性があり、さらに mutableState.update が連続実行されてイベント収集コルーチンがハングします。visited 集合で訪問済みセッションを検知する防御を追加してください。

Suggestion:

Suggested change
var parentId = parentIdOf(target, sessionId)
while (parentId != null) {
if (isRuntimeIdle(parentId)) break
val ancestorId = parentId
var parentId = parentIdOf(target, sessionId)
val visited = mutableSetOf<String>()
while (parentId != null) {
if (!visited.add(parentId)) break
if (isRuntimeIdle(parentId)) break
val ancestorId = parentId

mutableState.update { current ->
current.copy(
activeSessionIds = current.activeSessionIds + ancestorId,
completedSessionIds = current.completedSessionIds - ancestorId,
settledSessionIds = current.settledSessionIds - ancestorId,
)
}
parentId = parentIdOf(target, ancestorId)
}
}

private fun rememberParent(
sessionId: String,
parentId: String?,
) {
if (sessionId.isBlank()) return
synchronized(parentLock) { parentIds[sessionId] = parentId }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
API 解決に失敗した場合も null が parentIds に永続キャッシュされるため、「親なしと確認済み」と「解決失敗」が区別できません。一時的な API エラーで subagent の親解決が失敗すると、以後そのセッションは親なしとして扱われ、SessionIdle 時に onSessionIdle が誤発火したり activateAncestors が祖先を活性化できなくなったりしますが、キャッシュ済みのため再試行もされません。解決に成功した場合のみキャッシュする(失敗時はキャッシュしない)よう修正してください。

Suggestion:

Suggested change
synchronized(parentLock) { parentIds[sessionId] = parentId }
if (parentId != null || cachedParent(sessionId)) {
synchronized(parentLock) { parentIds[sessionId] = parentId }
}

}

/** Parent of a subagent session, resolved once per session and cached. */
private suspend fun parentIdOf(
target: RuntimeTarget,
sessionId: String,
): String? {
synchronized(parentLock) {
if (sessionId in parentIds) return parentIds[sessionId]
}
// Misses the creation event when the stream reconnected mid-run; ask the runtime instead.
val parentId = runCatching { target.session(sessionId).parentId }.getOrNull()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
target.session(sessionId) は suspend なネットワーク/プロセス呼び出しですが、runCatchingCancellationException も捕捉してしまうため、収集コルーチンがキャンセルされた際にキャンセレーションが握りつぶされ、構造的並行性が壊れます。さらに失敗結果の null が下の行でキャッシュされるため、キャンセル時に誤った親情報が永続化されます。CancellationException は必ず再送出してください。

Suggestion:

Suggested change
val parentId = runCatching { target.session(sessionId).parentId }.getOrNull()
val parentId = try {
target.session(sessionId).parentId
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}

synchronized(parentLock) { parentIds[sessionId] = parentId }
return parentId
}

private fun markRuntimeIdle(sessionId: String) {
synchronized(parentLock) { runtimeIdleSessionIds.add(sessionId) }
}

private fun markRuntimeRunning(sessionId: String) {
synchronized(parentLock) { runtimeIdleSessionIds.remove(sessionId) }
}

private fun isRuntimeIdle(sessionId: String): Boolean = synchronized(parentLock) { sessionId in runtimeIdleSessionIds }

private suspend fun sessionTitle(
target: RuntimeTarget,
sessionId: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ class AndCodeVoiceSession(context: Context) :
// handling session.status too would finish the same turn twice.
OpenCodeEvent.ServerConnected,
is OpenCodeEvent.MessageUpdated,
is OpenCodeEvent.SessionCreated,
is OpenCodeEvent.SessionUpdated,
is OpenCodeEvent.SessionStatusChanged,
is OpenCodeEvent.QuestionAsked,
is OpenCodeEvent.Unknown,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1489,6 +1489,8 @@ class ChatViewModel(
)
}
}
is OpenCodeEvent.SessionCreated -> Unit
is OpenCodeEvent.SessionUpdated -> Unit
is OpenCodeEvent.Unknown -> Unit
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,29 @@ class OpenCodeEventParserTest {
assertEquals("busy", event.status)
}

@Test
fun `parses session created event with its parent link`() {
val event =
parser.parse(
"""{"type":"session.created","properties":{"sessionID":"child1","info":{"id":"child1","slug":"happy-forest","parentID":"parent1","title":"Check dir (@general subagent)","time":{"created":1}}}}""",
) as OpenCodeEvent.SessionCreated

assertEquals("child1", event.session.id)
assertEquals("parent1", event.session.parentId)
assertEquals("Check dir (@general subagent)", event.session.title)
}

@Test
fun `parses session updated event`() {
val event =
parser.parse(
"""{"type":"session.updated","properties":{"sessionID":"parent1","info":{"id":"parent1","title":"Investigation","time":{"created":1,"updated":2}}}}""",
) as OpenCodeEvent.SessionUpdated

assertEquals("parent1", event.session.id)
assertEquals(null, event.session.parentId)
}

@Test
fun `session error reports the readable message instead of raw json`() {
val event =
Expand Down
Loading
Loading