fix: settle busy sessions after managed OpenCode restart (#3002)

* fix: reconcile busy sessions after managed OpenCode restart

Forced health-check restarts previously rebound the event stream without
settling in-flight turns, so sessions stayed busy with no terminal state.
Interrupt those sessions, classify health failures, and retain bounded
process diagnostics for post-restart diagnosis.

Fixes #2943

Co-authored-by: serkraser <serkraser@gmail.com>

* fix: surface interrupted chats after OpenCode restart

Complete unfinished assistant turns as aborted once the session is
authoritatively idle, and show a persistent toast so users can continue
instead of remaining silently stranded.

Fixes #2943

Co-authored-by: serkraser <serkraser@gmail.com>

* fix: redact Basic auth credentials in restart diagnostics

The key/value sanitizer stopped at whitespace, so Authorization: Basic
credentials survived in stderr tails and health snapshots. Redact the
scheme token before that rule runs.

Co-authored-by: serkraser <serkraser@gmail.com>
This commit is contained in:
Serhii Dziupin
2026-08-19 11:53:30 +03:00
committed by GitHub
parent 13a45aa5a5
commit 14d7a0ca9b
24 changed files with 806 additions and 80 deletions
+120 -43
View File
@@ -70,6 +70,7 @@ import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-mem
import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { isFilesystemError } from "@/lib/api/files-errors"
import { formatMessage, useI18nStore } from "@/lib/i18n"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
@@ -456,6 +457,32 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
}
const notification = properties as UiNotificationPayload
const kind = asOptionalString(notification.kind)
const sessionId = asOptionalString(notification.sessionId)
const directory = asOptionalString(notification.directory)
?? (fallbackDirectory !== "global" ? fallbackDirectory : "")
if (kind === "opencode-restart-interrupted") {
const dictionary = useI18nStore.getState().dictionary
const title = formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.title")
const options = {
id: "opencode-restart-interrupted",
description: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.description"),
duration: Infinity,
}
if (sessionId && directory) {
toast.info(title, {
...options,
action: {
label: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.openSession"),
onClick: () => openSessionFromToast(sessionId, directory),
},
})
} else {
toast.info(title, options)
}
}
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
return true
}
@@ -469,9 +496,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
title: asOptionalString(notification.title),
body: asOptionalString(notification.body),
tag: asOptionalString(notification.tag),
kind: asOptionalString(notification.kind),
sessionId: asOptionalString(notification.sessionId),
directory: asOptionalString(notification.directory) ?? (fallbackDirectory && fallbackDirectory !== "global" ? fallbackDirectory : undefined),
kind,
sessionId,
directory: directory || undefined,
requireHidden: notification.requireHidden === true,
}).catch((error) => {
console.warn("[notifications] failed to dispatch UI notification", error)
@@ -632,15 +659,24 @@ async function resyncDirectorySessionStatuses(
if (mode === "authoritative") {
applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds)
// An authoritative snapshot that settles sessions previously observed
// busy/retry can orphan running tool parts (managed process died
// mid-turn, #2577): finalize them now. The snapshot write above already
// lowered their status to explicit idle, which is the gate the helper
// requires — a session the snapshot reports busy stays untouched.
// busy/retry can leave their trailing assistant message and tool parts
// unfinished (managed process died mid-turn, #2577): finalize them now.
// The snapshot write above already lowered their status to explicit idle,
// which is the gate the helper requires — a session the snapshot reports
// busy stays untouched.
for (const sessionId of candidateSessionIds) {
const interrupted = interruptedTurnToolParts(store.getState(), sessionId)
if (interrupted) {
if (!interrupted.parts) {
store.setState((state) => ({
message: { ...state.message, [sessionId]: interrupted.messages },
}))
continue
}
const interruptedParts = interrupted.parts
store.setState((state) => ({
part: { ...state.part, [interrupted.messageID]: interrupted.parts },
message: { ...state.message, [sessionId]: interrupted.messages },
part: { ...state.part, [interrupted.messageID]: interruptedParts },
}))
}
}
@@ -1804,18 +1840,32 @@ export function handleEvent(
messageID,
})
}
// The reducer already wrote the idle/error status into `draft`; mark the
// orphaned tools using the batched state and publish through the batch.
// The reducer already wrote the idle/error status into `draft`; finalize
// the interrupted message and orphaned tools through the same batch.
if (sessionID) {
const interrupted = interruptedTurnToolParts(state, sessionID)
if (interrupted) {
cloneField("part", (value) => ({ ...(value ?? {}) }))
;(draft as DirectoryStore).part[interrupted.messageID] = interrupted.parts
cloneField("message", (value) => ({ ...value }))
draft.message[sessionID] = interrupted.messages
if (interrupted.parts) {
cloneField("part", (value) => ({ ...(value ?? {}) }))
draft.part[interrupted.messageID] = interrupted.parts
}
if (batch) {
batch.states.set(store, draft as DirectoryStore)
batch.changedStores.add(store)
} else {
store.setState({ part: { ...(store.getState().part), [interrupted.messageID]: interrupted.parts } })
const currentState = store.getState()
if (interrupted.parts) {
store.setState({
message: { ...currentState.message, [sessionID]: interrupted.messages },
part: { ...currentState.part, [interrupted.messageID]: interrupted.parts },
})
} else {
store.setState({
message: { ...currentState.message, [sessionID]: interrupted.messages },
})
}
}
}
}
@@ -1829,29 +1879,31 @@ export function handleEvent(
//
// A managed OpenCode process can die mid-turn (crash, health-check restart).
// The persisted turn then never settles: the trailing assistant message has
// no `time.completed` and its tool parts stay `pending`/`running` forever —
// the server never finalizes them (anomalyco/opencode#19023). The
// no `time.completed`, and any tool parts can stay `pending`/`running`
// forever — the server never finalizes them (anomalyco/opencode#19023). The
// settle-triggered tail refresh above refetches the same stale records, so
// the UI would keep running tool timers and "working" styling indefinitely
// (#2577).
// the UI would keep the assistant message unfinished and any tool timers and
// "working" styling active indefinitely (#2577).
//
// OpenCode keeps a turn's session busy while it is genuinely alive —
// including while waiting for a question/permission reply — so once a
// session is AUTHORITATIVELY settled (a `session.idle`/`session.error`
// event, or an authoritative status snapshot that lowers a previously busy
// session) and the trailing assistant message is still unfinished with
// active tool parts and no pending question/permission, the turn is
// definitively interrupted. Finalize the orphaned parts locally as
// `error`/`Interrupted` with an end time — the same shape OpenCode itself
// writes for cancelled tools. A later terminal part event or a refresh that
// carries the true terminal state supersedes the mark; a stale refresh that
// still reports `running` is rejected by the reducer's and the materializer's
// final-status preservation.
// no pending question/permission, the turn is definitively interrupted.
// Complete the assistant message locally with MessageAbortedError and finalize
// any orphaned parts as `error`/`Interrupted` with an end time — the same shape
// OpenCode itself writes for cancelled tools. A later terminal event can
// supersede the mark; a stale refresh cannot regress the locally final state.
type AssistantMessage = Extract<Message, { role: "assistant" }>
type SdkMessageAbortedError = Extract<NonNullable<AssistantMessage["error"]>, { name: "MessageAbortedError" }>
type LocalMessageAbortedError = SdkMessageAbortedError & { message: string }
export function interruptedTurnToolParts(
state: DirectoryStore,
sessionID: string,
now = Date.now(),
): { messageID: string; parts: Part[] } | null {
): { messageID: string; messages: Message[]; parts?: Part[] } | null {
if ((state.question?.[sessionID] ?? []).length > 0) return null
if ((state.permission?.[sessionID] ?? []).length > 0) return null
@@ -1863,37 +1915,62 @@ export function interruptedTurnToolParts(
return null
}
const messageID = getStaleRunningToolMessageID(state, sessionID)
if (!messageID) return null
const message = (state.message[sessionID] ?? []).find((candidate) => candidate.id === messageID)
if (!message) return null
if (typeof (message as { time?: { completed?: unknown } }).time?.completed === "number") {
const messages = state.message[sessionID] ?? []
let messageIndex = -1
for (let index = messages.length - 1; index >= 0; index -= 1) {
const candidate = messages[index]
if (candidate.role === "user") return null
if (candidate.role !== "assistant") continue
messageIndex = index
break
}
if (messageIndex < 0) return null
const message = messages[messageIndex]
if (message.role !== "assistant") return null
if (message.time.completed !== undefined) {
// The turn finished; a missed terminal tool event is the tail refresh's
// job, not an interruption.
return null
}
const current = state.part[messageID]
if (!current) return null
const messageID = message.id
const nextMessages = [...messages]
const error = {
name: "MessageAbortedError",
data: { message: "aborted" },
message: "aborted",
} satisfies LocalMessageAbortedError
nextMessages[messageIndex] = {
...message,
time: { ...message.time, completed: now },
error,
}
let changed = false
const nextParts = current.map((part) => {
let partsChanged = false
const currentParts = state.part[messageID]
const nextParts = currentParts?.map((part) => {
if (part.type !== "tool") return part
const partState = (part as { state?: { status?: unknown; time?: { start?: number } } }).state
if (!partState) return part
if (partState.status !== "pending" && partState.status !== "running") return part
changed = true
if (part.state.status !== "pending" && part.state.status !== "running") return part
partsChanged = true
const partTime = "time" in part.state ? part.state.time : undefined
const start = typeof partTime?.start === "number" ? partTime.start : now
return {
...part,
state: {
...partState,
status: "error",
...part.state,
status: "error" as const,
error: "Interrupted",
time: { ...(partState.time ?? {}), end: now },
time: { start, end: now },
},
} as Part
}
})
return changed ? { messageID, parts: nextParts } : null
return {
messageID,
messages: nextMessages,
parts: partsChanged ? nextParts : undefined,
}
}
// ---------------------------------------------------------------------------