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
@@ -3,7 +3,7 @@
* process dies mid-turn, the persisted turn never settles — the trailing
* assistant message has no time.completed and its tool parts stay running.
* Once the session is authoritatively settled, `interruptedTurnToolParts`
* finalizes the orphaned parts locally.
* completes the assistant message as aborted and finalizes orphaned parts.
*/
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -74,10 +74,15 @@ describe("interruptedTurnToolParts (#2577)", () => {
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const part = result!.parts[0] as { state: { status: string; error: string; time: { end: number } } }
const part = result!.parts![0] as { state: { status: string; error: string; time: { end: number } } }
expect(part.state.status).toBe("error")
expect(part.state.error).toBe("Interrupted")
expect(part.state.time.end).toBe(5000)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
})
test("busy session is never marked (live work)", () => {
@@ -137,16 +142,43 @@ describe("interruptedTurnToolParts (#2577)", () => {
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const statuses = result!.parts.map((part) => (part as { state: { status: string } }).state.status)
const statuses = result!.parts!.map((part) => (part as { state: { status: string } }).state.status)
expect(statuses).toEqual(["error", "completed", "error"])
})
test("no active parts → no change", () => {
test("unfinished assistant with no tools is completed as aborted", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [completedTool("tool_2", "msg_1")] },
part: {},
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
expect(result!.parts).toBe(undefined)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
})
test("completed tools are untouched while the unfinished assistant is aborted", () => {
const completed = completedTool("tool_2", "msg_1")
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [completed] },
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
expect(result!.parts).toBe(undefined)
expect(store.part.msg_1[0]).toBe(completed)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
})
@@ -119,6 +119,31 @@ describe("materializeSessionSnapshots", () => {
expect(result.part.msg_1[0]).toBe(livePart)
})
test("preserves a locally aborted assistant message when a stale unfinished snapshot arrives", () => {
const unfinishedMessage = message("msg_1")
if (unfinishedMessage.role !== "assistant") throw new Error("Expected assistant fixture")
const abortedMessage: Message = {
...unfinishedMessage,
time: { created: 1, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" } },
}
const staleMessage = message("msg_1")
const state = {
message: { ses_1: [abortedMessage] },
part: { msg_1: [] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: staleMessage, parts: [] }],
)
expect(result.message).toBe(state.message)
expect(result.message.ses_1[0]).toBe(abortedMessage)
expect(result.message.ses_1[0]).not.toBe(staleMessage)
})
test("does not preserve omitted optimistic user text parts beside server snapshot parts", () => {
const optimisticPart = { id: "prt_optimistic", messageID: "msg_1", type: "text", text: "Hello" } as Part
const serverPart = part("prt_server", "msg_1", "text", "Hello")