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