cli/run: switch to global event stream (#26383)
This commit is contained in:
@@ -487,6 +487,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
|
||||
const handle = await mod.createSessionTransport({
|
||||
sdk: ctx.sdk,
|
||||
directory: ctx.directory,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// SDK event subscription and prompt turn coordination.
|
||||
// Global event subscription and prompt turn coordination.
|
||||
//
|
||||
// Creates a long-lived event stream subscription and feeds every event
|
||||
// through the session-data reducer. The reducer produces scrollback commits
|
||||
// and footer patches, which get forwarded to the footer through stream.ts.
|
||||
// Creates a long-lived global event stream subscription and feeds relevant
|
||||
// events for the current session tree through the reducers. The reducers
|
||||
// produce scrollback commits and footer patches, which get forwarded to the
|
||||
// footer through stream.ts.
|
||||
//
|
||||
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
|
||||
// SDK, arms a deferred Wait, and resolves when the session becomes idle.
|
||||
@@ -14,7 +15,7 @@
|
||||
// The tick counter prevents stale idle events from resolving the wrong turn.
|
||||
// We also re-check live session status before resolving an idle event so a
|
||||
// delayed idle from an older turn cannot complete a newer busy turn.
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ type Trace = {
|
||||
|
||||
type StreamInput = {
|
||||
sdk: OpencodeClient
|
||||
directory?: string
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: () => Record<string, number>
|
||||
@@ -151,6 +153,40 @@ function isEvent(value: unknown): value is Event {
|
||||
return typeof type === "string" && !!properties && typeof properties === "object"
|
||||
}
|
||||
|
||||
function isGlobalEvent(value: unknown): value is GlobalEvent {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const payload = Reflect.get(value, "payload")
|
||||
return !!payload && typeof payload === "object"
|
||||
}
|
||||
|
||||
function globalPayloadEvent(value: unknown): Event | undefined {
|
||||
if (!isGlobalEvent(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload = value.payload
|
||||
if (payload.type === "sync") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return isEvent(payload) ? payload : undefined
|
||||
}
|
||||
|
||||
function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean {
|
||||
if (!directory || !isGlobalEvent(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (value.directory !== directory) {
|
||||
return false
|
||||
}
|
||||
|
||||
return value.payload.type === "server.instance.disposed"
|
||||
}
|
||||
|
||||
function active(event: Event, sessionID: string): boolean {
|
||||
if (sid(event) !== sessionID) {
|
||||
return false
|
||||
@@ -371,7 +407,7 @@ function createLayer(input: StreamInput) {
|
||||
const events = yield* Scope.provide(scope)(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() =>
|
||||
input.sdk.event.subscribe(undefined, {
|
||||
input.sdk.global.event({
|
||||
signal: abort.signal,
|
||||
}),
|
||||
),
|
||||
@@ -397,7 +433,6 @@ function createLayer(input: StreamInput) {
|
||||
blockers: new Map(),
|
||||
}
|
||||
const recovering = new Set<string>()
|
||||
|
||||
const currentSubagentState = () => {
|
||||
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
|
||||
state.selectedSubagent = undefined
|
||||
@@ -526,6 +561,38 @@ function createLayer(input: StreamInput) {
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
|
||||
const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(
|
||||
function* (sessions: string[]) {
|
||||
yield* Effect.forEach(
|
||||
sessions,
|
||||
(sessionID) =>
|
||||
messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe(
|
||||
Effect.tap((messagesList) =>
|
||||
Effect.sync(() => {
|
||||
if (
|
||||
!bootstrapSubagentCalls({
|
||||
data: state.subagent,
|
||||
sessionID,
|
||||
messages: messagesList,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
syncFooter([], undefined, currentSubagentState())
|
||||
}),
|
||||
),
|
||||
),
|
||||
{
|
||||
concurrency: 4,
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
|
||||
const [messagesList, children, permissions, questions] = yield* Effect.all(
|
||||
[
|
||||
@@ -566,33 +633,6 @@ function createLayer(input: StreamInput) {
|
||||
questions,
|
||||
})
|
||||
|
||||
const sessions = [
|
||||
...new Set(
|
||||
listSubagentPermissions(state.subagent)
|
||||
.filter((item) => item.tool && item.metadata?.input === undefined)
|
||||
.map((item) => item.sessionID),
|
||||
),
|
||||
]
|
||||
yield* Effect.forEach(
|
||||
sessions,
|
||||
(sessionID) =>
|
||||
messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe(
|
||||
Effect.tap((messagesList) =>
|
||||
Effect.sync(() => {
|
||||
bootstrapSubagentCalls({
|
||||
data: state.subagent,
|
||||
sessionID,
|
||||
messages: messagesList,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
|
||||
for (const request of [
|
||||
...state.data.permissions,
|
||||
...listSubagentPermissions(state.subagent),
|
||||
@@ -605,6 +645,13 @@ function createLayer(input: StreamInput) {
|
||||
const snapshot = currentSubagentState()
|
||||
traceTabs(input.trace, [], snapshot.tabs)
|
||||
syncFooter([], undefined, snapshot)
|
||||
|
||||
const sessions = [...state.subagent.tabs.keys()]
|
||||
if (sessions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
yield* bootstrapSubagentHistory(sessions).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
})
|
||||
|
||||
const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) =>
|
||||
@@ -700,11 +747,22 @@ function createLayer(input: StreamInput) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEvent(item)) {
|
||||
if (isMatchingDisposeEvent(item, input.directory)) {
|
||||
yield* fail(new Error("instance disposed"))
|
||||
yield* closeScope()
|
||||
return
|
||||
}
|
||||
|
||||
const event = globalPayloadEvent(item)
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionID = sid(event)
|
||||
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
|
||||
return
|
||||
}
|
||||
|
||||
const event = item
|
||||
input.trace?.write("recv.event", event)
|
||||
trackBlocker(event)
|
||||
|
||||
@@ -754,7 +812,7 @@ function createLayer(input: StreamInput) {
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
if (!abort.signal.aborted && !state.fault) {
|
||||
yield* fail(new Error("session event stream closed"))
|
||||
yield* fail(new Error("global event stream closed"))
|
||||
}
|
||||
closeStream()
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import * as Locale from "@/util/locale"
|
||||
import {
|
||||
bootstrapSessionData,
|
||||
@@ -22,6 +22,10 @@ type SessionMessage = {
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type BootstrapChildMessage = SessionMessage & {
|
||||
info: Message
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
key: string
|
||||
commit: StreamCommit
|
||||
@@ -513,6 +517,70 @@ function applyChildEvent(input: {
|
||||
return changed || queueChanged(input.detail.data, before)
|
||||
}
|
||||
|
||||
function bootstrapChildEvent(input: {
|
||||
detail: DetailState
|
||||
event: Event
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}) {
|
||||
const out = reduceSessionData({
|
||||
data: input.detail.data,
|
||||
event: input.event,
|
||||
sessionID: input.detail.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
})
|
||||
|
||||
return appendCommits(input.detail, out.commits)
|
||||
}
|
||||
|
||||
function bootstrapChildMessages(input: {
|
||||
detail: DetailState
|
||||
messages: BootstrapChildMessage[]
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}) {
|
||||
let changed = false
|
||||
|
||||
for (const message of input.messages) {
|
||||
changed =
|
||||
bootstrapChildEvent({
|
||||
detail: input.detail,
|
||||
event: {
|
||||
id: `bootstrap:message:${message.info.id}`,
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: input.detail.sessionID,
|
||||
info: message.info,
|
||||
},
|
||||
},
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
}) || changed
|
||||
|
||||
for (const part of message.parts) {
|
||||
changed =
|
||||
bootstrapChildEvent({
|
||||
detail: input.detail,
|
||||
event: {
|
||||
id: `bootstrap:part:${part.id}`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: input.detail.sessionID,
|
||||
part,
|
||||
time: 0,
|
||||
},
|
||||
},
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
}) || changed
|
||||
}
|
||||
}
|
||||
|
||||
compactDetail(input.detail)
|
||||
return changed
|
||||
}
|
||||
|
||||
function knownSession(data: SubagentData, sessionID: string) {
|
||||
return data.tabs.has(sessionID)
|
||||
}
|
||||
@@ -634,7 +702,13 @@ export function bootstrapSubagentData(input: BootstrapSubagentInput) {
|
||||
return changed
|
||||
}
|
||||
|
||||
export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
|
||||
export function bootstrapSubagentCalls(input: {
|
||||
data: SubagentData
|
||||
sessionID: string
|
||||
messages: BootstrapChildMessage[]
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}) {
|
||||
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
@@ -648,9 +722,14 @@ export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: s
|
||||
permissions: detail.data.permissions,
|
||||
questions: detail.data.questions,
|
||||
})
|
||||
compactDetail(detail)
|
||||
const changed = bootstrapChildMessages({
|
||||
detail,
|
||||
messages: input.messages,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
})
|
||||
|
||||
return beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
|
||||
return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
|
||||
}
|
||||
|
||||
export function clearFinishedSubagents(data: SubagentData) {
|
||||
|
||||
Reference in New Issue
Block a user