fix(sync): finalize tool parts orphaned by an interrupted turn after settlement

When a managed OpenCode process dies mid-turn (crash, health-check
restart), the persisted turn 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 existing settle-triggered tail refresh refetches the same stale
records, so the UI kept running tool timers and working styling
indefinitely (#2577).

Now, when a session is authoritatively settled (session.idle/
session.error event, or an authoritative status snapshot lowering a
previously busy session) and the trailing assistant message is still
unfinished with active tool parts and no pending question/permission,
the orphaned parts are finalized locally as error/"Interrupted" with
an end time — the same shape OpenCode itself writes for cancelled
tools. The mark is gated on an explicit idle status (absent status is
"unknown", never judged), never applies while busy (including
question/permission waits), and a later terminal event or refresh
supersedes it while a stale running refresh cannot regress it (the
reducer and materializer already preserve final statuses).

Fixes #2577
This commit is contained in:
Serhii Dziupin
2026-08-05 14:15:03 +03:00
parent 34c221b07f
commit e3202df037
4 changed files with 287 additions and 0 deletions
@@ -167,6 +167,39 @@ describe("materializeSessionSnapshots", () => {
expect(mergedPart.state?.time?.end).toBe(2000)
})
test("does not regress a locally interrupted tool (error + end) when a stale running snapshot arrives", () => {
// The #2577 mark writes status "error" + end time; a later stale refresh
// that still reports the part as running must not undo it.
const interruptedTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "error", error: "Interrupted", time: { start: 1000, end: 5000 } },
} as unknown as Part
const staleRunningTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running", time: { start: 1000 } },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [interruptedTool] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [staleRunningTool] }],
)
expect(result.part.msg_1[0]).toBe(interruptedTool)
expect(result.part.msg_1[0]).not.toBe(staleRunningTool)
expect((result.part.msg_1[0] as { state: { status: string } }).state.status).toBe("error")
})
test("does not regress a completed tool when a stale running snapshot arrives", () => {
const completedTool = {
id: "prt_1",