Skip to content
Merged
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
54 changes: 35 additions & 19 deletions packages/app-bundle/manifest.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,13 @@ export function SessionContextTab() {
return map.get(mount) === false
}

const busy = createMemo(() => (sync().data.session_status[params.id ?? ""]?.type ?? "idle") !== "idle")
// #1637: the context-tab busy memo reflects session_working (the turn-active
// floor), NOT raw status type, so a stray idle during a live turn cannot flip
// a turn to done in the context tree.
const busy = createMemo(() => {
const id = params.id
return !!id && sync().data.session_working(id)
})

const turns = createMemo<ContextTurn[]>(() => {
const byPrompt = new Map<string, ContextTurn>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import { sessionWorkingFromStore } from "../session-working"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
Expand Down Expand Up @@ -229,9 +230,12 @@ export function createChildStoreManager(input: {
session: [],
sessionTotal: 0,
session_status: {},
session_turn_active: {},
session_working(id: string) {
const type = this.session_status[id]?.type
return (type ?? "idle") !== "idle"
// #1637 — honor the SAME turn-active floor as the server-session
// store: working when the turn flag is up (no-part turns / stray
// idle) OR the status is not idle.
return sessionWorkingFromStore(this.session_status[id], this.session_turn_active?.[id] ?? false)
},
session_diff: {},
todo: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const baseState = (input: Partial<State> = {}) =>
session: [],
sessionTotal: 0,
session_status: {},
session_turn_active: {},
session_diff: {},
todo: {},
permission: {},
Expand Down Expand Up @@ -656,4 +657,70 @@ describe("applyDirectoryEvent", () => {
// The gate prevents this for events with `origin: "studio"`.
})

// ── #1637 — the child-store's second session_working honors the turn floor ──
// The global-sync child store's reducer previously wrote status ONLY from
// session.status frames — the execution bracket never reached it, so its
// session_working had no turn floor. Plumb the flag: execution.started sets it,
// the terminals / session.error / an idle status frame clear it.
test("#1637 the child-store reducer raises session_turn_active on execution.started", () => {
const [store, setStore] = createStore(baseState())
applyDirectoryEvent({
event: { type: "session.execution.started", properties: { sessionID: "ses_1" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session_turn_active?.ses_1).toBe(true)
})

test("#1637 the child-store reducer clears session_turn_active on a terminal execution event", () => {
const [store, setStore] = createStore(baseState({ session_turn_active: { ses_1: true } }))
applyDirectoryEvent({
event: { type: "session.execution.succeeded", properties: { sessionID: "ses_1" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session_turn_active?.ses_1).toBe(false)
})

test("#1637 the child-store reducer clears session_turn_active on session.error", () => {
const [store, setStore] = createStore(baseState({ session_turn_active: { ses_1: true } }))
applyDirectoryEvent({
event: { type: "session.error", properties: { sessionID: "ses_1" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session_turn_active?.ses_1).toBe(false)
})

test("#1637 the child-store reducer honors status seq (drops a stale idle)", () => {
const [store, setStore] = createStore(baseState({ session_status: { ses_1: { type: "busy" } } }))
applyDirectoryEvent({
event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" }, seq: 5 } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
// A lower-seq idle is dropped — the busy stands.
applyDirectoryEvent({
event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" }, seq: 3 } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session_status.ses_1?.type).toBe("busy")
})
})

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
"session.status",
// #1637 — the turn-active floor's events are session content too (they feed
// session_working); gate them with the status frame in passive directories.
"session.execution.started",
"session.execution.succeeded",
"session.execution.failed",
"session.execution.interrupted",
"session.error",
"message.updated",
"message.removed",
"message.part.updated",
Expand All @@ -32,6 +39,9 @@ const SESSION_CONTENT_EVENTS = new Set([
"question.replied",
"question.rejected",
])
// #1637 — sessionID → max status seq seen (#1636), for the child store's seq
// guard. Sessions are unique across directories, so a module map is safe.
const childStatusSeq = new Map<string, number>()

export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
Expand Down Expand Up @@ -264,10 +274,52 @@ export function applyDirectoryEvent(input: {
break
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
const props = event.properties as { sessionID: string; status: SessionStatus; seq?: number }
// #1637 honor seq (#1636): drop a stale/out-of-order frame so a reordered
// idle cannot overwrite a live busy. Absent seq → always apply.
if (typeof props.seq === "number") {
const seen = childStatusSeq.get(props.sessionID)
if (seen !== undefined && props.seq <= seen) break
childStatusSeq.set(props.sessionID, props.seq)
}
// #1637 fallback clear: an authoritative idle frame ends the turn bracket.
if ((props.status?.type ?? "idle") === "idle")
input.setStore("session_turn_active", props.sessionID, false)
input.setStore("session_status", props.sessionID, reconcile(props.status))
break
}
case "session.execution.started": {
// #1637 turn-active floor: rises on the execution bracket the server emits
// for EVERY turn shape (part-bearing or not).
const props = event.properties as { sessionID?: string } | undefined
if (props?.sessionID) {
input.setStore("session_turn_active", props.sessionID, true)
input.setStore("session_status", props.sessionID, reconcile({ type: "busy" }))
}
break
}
case "session.execution.succeeded":
case "session.execution.failed":
case "session.execution.interrupted": {
// #1637 terminal clear.
const props = event.properties as { sessionID?: string } | undefined
if (props?.sessionID) {
input.setStore("session_turn_active", props.sessionID, false)
input.setStore("session_status", props.sessionID, reconcile({ type: "idle" }))
}
break
}
case "session.error": {
// #1637 fallback clear: a Session.Event.Error site / abort can skip the
// terminal execution event.
const props = event.properties as { sessionID?: string } | undefined
if (props?.sessionID) {
input.setStore("session_turn_active", props.sessionID, false)
if ((input.store.session_status[props.sessionID]?.type ?? "idle") !== "idle")
input.setStore("session_status", props.sessionID, reconcile({ type: "idle" }))
}
break
}
case "message.updated": {
const info = clean((event.properties as { info: Message }).info)
const messages = input.store.message[info.sessionID]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const SESSION_CACHE_LIMIT = 128

type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_turn_active?: Record<string, boolean | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
Expand Down Expand Up @@ -44,6 +45,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.session_message[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
if (store.session_turn_active) delete store.session_turn_active[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export type State = {
session_status: {
[sessionID: string]: SessionStatus
}
/**
* #1637 — sessionID → the turn is inside a `session.execution.started` bracket
* whose terminal / fallback clear has not yet arrived. Feeds the turn-active
* floor read by `session_working` so a no-part turn (and a stray idle) cannot
* blank a live rail.
*/
session_turn_active?: {
[sessionID: string]: boolean
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: FileDiffInfo[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
// handlers; a no-op if the stream is already down (attempt undefined).
const forceReconnect = () => {
lastFrameAt = Date.now()
attempt?.abort()
// #1637: a silent-socket / watchdog forced reconnect must bump the resync
// token so the entity rail self-heals a stranded view — a WEDGE keeps the
// socket nominally connected, so the disconnect→connect edge never fires and
// the rail would otherwise never re-fetch. Bump only when we actually abort a
// parked attempt (a real forced resync), not on the already-down no-op.
if (attempt) {
attempt.abort()
setResyncCount((n) => n + 1)
}
}

const start = () => {
Expand Down
Loading
Loading