Merge branch 'main' into feat/gh-2634-pending-question
This commit is contained in:
@@ -204,6 +204,8 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se
|
||||
|
||||
When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status.
|
||||
|
||||
When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with active tool parts and no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the parts, see openchamber#2577 / anomalyco/opencode#19023). The active parts are finalized locally as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event or refresh supersedes it while a stale `running` refresh cannot regress it.
|
||||
|
||||
Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
|
||||
|
||||
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
|
||||
@@ -255,6 +257,10 @@ Examples of global-store updates performed in `session-actions.ts`:
|
||||
- `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state
|
||||
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
|
||||
|
||||
### Blocking-request (question/permission) reply routing
|
||||
|
||||
`respondToQuestion`, `rejectQuestion`, `respondToPermission`, and `dismissPermission` route the reply through `resolveDirectoryForBlockingRequest`. The directory chosen decides which OpenCode instance resolves the pending request, so it must be the **session record's own server-confirmed directory** (ownership), never the containing child-store key (containment): a project store legitimately holds its worktree sessions, and a reply addressed to the parent instance makes the server answer `QuestionNotFoundError` while the question stays pending in the worktree instance — the session is then stuck on the running question tool with no recovery. When a reply/reject comes back not-found, the stale request is removed locally and a `settled-running-tool` tail materialization is enqueued so the trailing tool part converges to the server's actual state instead of leaving the UI on "asking question" forever.
|
||||
|
||||
### Restore (unarchive) contract
|
||||
|
||||
The OpenCode server cannot clear `time.archived` over HTTP: `session.update`
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tests for interrupted-turn reconciliation (#2577): when a managed OpenCode
|
||||
* 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.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { interruptedTurnToolParts } from "../sync-context"
|
||||
import type { DirectoryStore } from "../child-store"
|
||||
import { INITIAL_STATE } from "../types"
|
||||
|
||||
function state(overrides: Partial<DirectoryStore> = {}): DirectoryStore {
|
||||
return {
|
||||
...INITIAL_STATE,
|
||||
session_status: {},
|
||||
message: {},
|
||||
part: {},
|
||||
question: {},
|
||||
permission: {},
|
||||
...overrides,
|
||||
} as unknown as DirectoryStore
|
||||
}
|
||||
|
||||
function runningTool(id: string, messageID: string, start = 1000): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "running", time: { start }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function completedTool(id: string, messageID: string): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "completed", time: { start: 1000, end: 2000 }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function pendingTool(id: string, messageID: string): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "pending", time: { start: 1000 }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function unfinishedAssistantMessage(id: string): Message {
|
||||
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10 } } as unknown as Message
|
||||
}
|
||||
|
||||
function finishedAssistantMessage(id: string): Message {
|
||||
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10, completed: 2000 } } as unknown as Message
|
||||
}
|
||||
|
||||
describe("interruptedTurnToolParts (#2577)", () => {
|
||||
test("settled session with unfinished message and running tool finalizes the part", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
|
||||
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 } } }
|
||||
expect(part.state.status).toBe("error")
|
||||
expect(part.state.error).toBe("Interrupted")
|
||||
expect(part.state.time.end).toBe(5000)
|
||||
})
|
||||
|
||||
test("busy session is never marked (live work)", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("absent status is unknown, not settled — never marked", () => {
|
||||
const store = state({
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("finished message is not an interruption (tail refresh reconciles it)", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [finishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("pending question means the turn is waiting for input, not interrupted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
question: { ses_1: [{ id: "q_1", sessionID: "ses_1", questions: [{ question: "?", header: "h", options: [{ label: "a", description: "" }] }] }] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("pending permission means the turn is waiting for input, not interrupted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
permission: { ses_1: [{ id: "p_1", sessionID: "ses_1", permission: "bash", patterns: [], metadata: {}, always: [] }] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("only active parts are finalized; completed parts are untouched", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: {
|
||||
msg_1: [runningTool("tool_1", "msg_1"), completedTool("tool_2", "msg_1"), pendingTool("tool_3", "msg_1")],
|
||||
},
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
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", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [completedTool("tool_2", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -1461,6 +1461,165 @@ describe("rejectQuestion passes directory", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("blocking request reply routing and stale recovery (issue OPE-236)", () => {
|
||||
const materializationCalls: Array<{ directory: string; sessionID: string; messageID: string }> = []
|
||||
const enqueueMaterialization = (directory: string, sessionID: string, messageID: string) => {
|
||||
materializationCalls.push({ directory, sessionID, messageID })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
scopedClientDirectories.length = 0
|
||||
questionReplyError = null
|
||||
questionRejectError = null
|
||||
materializationCalls.length = 0
|
||||
})
|
||||
|
||||
test("routes the question reply by the request's own session directory, not the containing store key", async () => {
|
||||
// The question was asked by a worktree session whose record lives in the
|
||||
// parent store (containment). The reply must be addressed to the session's
|
||||
// own server-confirmed directory — otherwise the server resolves the
|
||||
// parent instance, does not find the pending question, and answers
|
||||
// QuestionNotFoundError, leaving the session stuck on "asking question".
|
||||
const question = buildQuestion("q-wt", "session-wt")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-wt", directory: "/test/project/wt" } as Session],
|
||||
question: { "session-wt": [question] },
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
await respondToQuestion("session-wt", "q-wt", [["Yes"]])
|
||||
|
||||
expect(scopedClientDirectories).toEqual(["/test/project/wt"])
|
||||
expect(replyCalls[0]?.params.directory).toBe("/test/project/wt")
|
||||
expect(replyCalls[0]?.params.requestID).toBe("q-wt")
|
||||
})
|
||||
|
||||
test("routes permission replies by the request's own session directory", async () => {
|
||||
const permission = buildPermission("perm-wt", "session-wt")
|
||||
const store = createStore(
|
||||
{ "session-wt": [permission] },
|
||||
{
|
||||
session: [{ id: "session-wt", directory: "/test/project/wt" } as Session],
|
||||
},
|
||||
)
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
await respondToPermission("session-wt", "perm-wt", "once")
|
||||
|
||||
expect(scopedClientDirectories).toEqual(["/test/project/wt"])
|
||||
expect(replyCalls[0]?.params.directory).toBe("/test/project/wt")
|
||||
expect(replyCalls[0]?.params.requestID).toBe("perm-wt")
|
||||
})
|
||||
|
||||
test("falls back to the containing store key when the session record carries no directory", async () => {
|
||||
const question = buildQuestion("q-1", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-a" } as Session],
|
||||
question: { "session-a": [question] },
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
await respondToQuestion("session-a", "q-1", [["Yes"]])
|
||||
|
||||
expect(scopedClientDirectories).toEqual(["/test/project"])
|
||||
expect(replyCalls[0]?.params.directory).toBe("/test/project")
|
||||
})
|
||||
|
||||
test("enqueues settled-running-tool tail recovery when the question reply is not found", async () => {
|
||||
const question = buildQuestion("q-stale", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-a" } as Session],
|
||||
question: { "session-a": [question] },
|
||||
message: {
|
||||
"session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message],
|
||||
},
|
||||
part: {
|
||||
"msg-1": [{
|
||||
id: "prt-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "session-a",
|
||||
type: "tool",
|
||||
tool: "question",
|
||||
state: { status: "running" },
|
||||
} as Part],
|
||||
},
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
questionReplyError = Object.assign(new Error("question.reply failed (404): QuestionNotFoundError"), { status: 404 })
|
||||
|
||||
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
await respondToQuestion("session-a", "q-stale", [["Yes"]])
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
// The stale request is gone from the store and the trailing running tool
|
||||
// part is reconciled instead of leaving the UI stuck on "asking question".
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }])
|
||||
})
|
||||
|
||||
test("enqueues tail recovery on reject not-found but not on success", async () => {
|
||||
const question = buildQuestion("q-1", "session-a")
|
||||
const store = createStore({}, {
|
||||
session: [{ id: "session-a" } as Session],
|
||||
question: { "session-a": [question] },
|
||||
message: {
|
||||
"session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message],
|
||||
},
|
||||
part: {
|
||||
"msg-1": [{
|
||||
id: "prt-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "session-a",
|
||||
type: "tool",
|
||||
tool: "question",
|
||||
state: { status: "running" },
|
||||
} as Part],
|
||||
},
|
||||
})
|
||||
const childStores = createChildStores([["/test/project", store]])
|
||||
|
||||
const { setActionRefs, rejectQuestion } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
||||
|
||||
// Success: no recovery enqueued — the normal question.rejected event flow clears state.
|
||||
await rejectQuestion("session-a", "q-1")
|
||||
expect(materializationCalls).toEqual([])
|
||||
|
||||
// Not-found: the request is stale server-side; the tail must be reconciled.
|
||||
questionRejectError = Object.assign(new Error("question.reject failed (404): QuestionNotFoundError"), { status: 404 })
|
||||
const stale = buildQuestion("q-stale", "session-a")
|
||||
store.setState({ question: { "session-a": [stale] } })
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
await rejectQuestion("session-a", "q-stale")
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
expect(store.getState().question["session-a"]).toBe(undefined)
|
||||
expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }])
|
||||
})
|
||||
})
|
||||
|
||||
function buildQuestion(id: string, sessionId: string): QuestionRequest {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -30,6 +30,8 @@ import { getImperativeSessionMessageLoader } from "./session-message-loader"
|
||||
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||
import { getStaleRunningToolMessageID } from "./materialization"
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
|
||||
const MESSAGE_REFETCH_LIMIT = 100
|
||||
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
||||
@@ -52,6 +54,10 @@ const UNREVERT_REFETCH_RETRY_MS = 150
|
||||
let _sdk: OpencodeClient | null = null
|
||||
let _childStores: ChildStoreManager | null = null
|
||||
let _getDirectory: () => string = () => ""
|
||||
// Optional ref into the sync layer's session-tail materialization queue. Used
|
||||
// to reconcile a trailing running tool part after a blocking request is
|
||||
// confirmed stale server-side (see recoverStaleBlockingRequest).
|
||||
let _enqueueSessionMaterialization: ((directory: string, sessionID: string, messageID: string) => void) | null = null
|
||||
type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
|
||||
type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string }
|
||||
type OptimisticConfirmInput = OptimisticRemoveInput
|
||||
@@ -139,10 +145,12 @@ export function setActionRefs(
|
||||
sdk: OpencodeClient,
|
||||
childStores: ChildStoreManager,
|
||||
getDirectory: () => string,
|
||||
enqueueSessionMaterialization?: (directory: string, sessionID: string, messageID: string) => void,
|
||||
) {
|
||||
_sdk = sdk
|
||||
_childStores = childStores
|
||||
_getDirectory = getDirectory
|
||||
_enqueueSessionMaterialization = enqueueSessionMaterialization ?? null
|
||||
}
|
||||
|
||||
export function setOptimisticRefs(
|
||||
@@ -480,6 +488,29 @@ function restoreFilePartsToInput(fileParts: Array<Record<string, unknown>>): voi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-confirmed directory that owns a session, from the session record
|
||||
* (`directory`, then `project.worktree`). Mirrors the authoritative source in
|
||||
* session-directory-resolution: holding a session in a child store proves
|
||||
* containment, not ownership — a project's session list legitimately includes
|
||||
* the sessions of its worktrees so the sidebar can group them — so reading
|
||||
* ownership from the containing store reports the parent for a session that
|
||||
* lives in a worktree, and every fetch is then addressed to a directory that
|
||||
* does not own it.
|
||||
*/
|
||||
function resolveSessionOwnedDirectory(session: Session): string | null {
|
||||
const record = session as Session & {
|
||||
directory?: string | null
|
||||
project?: { worktree?: string | null } | null
|
||||
}
|
||||
const raw = typeof record.directory === "string" && record.directory.trim().length > 0
|
||||
? record.directory
|
||||
: typeof record.project?.worktree === "string" && record.project.worktree.trim().length > 0
|
||||
? record.project.worktree
|
||||
: null
|
||||
return raw ? normalizePath(raw) : null
|
||||
}
|
||||
|
||||
function resolveDirectoryForBlockingRequest(
|
||||
type: "permission" | "question",
|
||||
sessionId: string,
|
||||
@@ -493,10 +524,28 @@ function resolveDirectoryForBlockingRequest(
|
||||
for (const [directory, store] of stores.children) {
|
||||
const state = store.getState()
|
||||
const requestMap = type === "permission" ? state.permission : state.question
|
||||
for (const requests of Object.values(requestMap) as Array<Array<{ id: string }> | undefined>) {
|
||||
if (requests?.some((request) => request.id === requestId)) {
|
||||
return directory
|
||||
}
|
||||
for (const requests of Object.values(requestMap) as Array<Array<{ id: string; sessionID?: string }> | undefined>) {
|
||||
const request = requests?.find((candidate) => candidate.id === requestId)
|
||||
if (!request) continue
|
||||
|
||||
// Ownership beats containment. The request belongs to one specific
|
||||
// session, and the reply must reach the instance that actually tracks
|
||||
// it — the directory the session record's server-confirmed `directory`
|
||||
// names. The containing store's key only proves containment: a project
|
||||
// store holds its worktree sessions too, and a reply addressed to the
|
||||
// parent instance makes the server answer QuestionNotFoundError while
|
||||
// the question stays pending in the worktree instance, leaving the
|
||||
// session stuck on the running question tool. Fall back to the store
|
||||
// key only when the session record carries no directory.
|
||||
const requestSessionID = typeof request.sessionID === "string" && request.sessionID.length > 0
|
||||
? request.sessionID
|
||||
: sessionId
|
||||
const sessionRecord = requestSessionID
|
||||
? state.session.find((s) => s.id === requestSessionID)
|
||||
: undefined
|
||||
const ownedDirectory = sessionRecord ? resolveSessionOwnedDirectory(sessionRecord) : null
|
||||
if (ownedDirectory) return ownedDirectory
|
||||
return directory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +586,38 @@ export function isQuestionRequestNotFoundError(error: unknown): boolean {
|
||||
return /Question(?:\.)?NotFoundError|Question request not found/i.test(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the trailing assistant tool part after a blocking request turned
|
||||
* out to be stale server-side (reply/reject answered with not-found). The
|
||||
* local request is removed (the server no longer tracks it), but the
|
||||
* question/permission tool part can remain `running` with the session busy —
|
||||
* the UI would stay on "asking question" with no recovery until the user
|
||||
* stops the run. Enqueue the sync layer's settled-running-tool tail
|
||||
* materialization so the part converges to the server's actual state.
|
||||
*/
|
||||
function recoverStaleBlockingRequest(sessionId: string): void {
|
||||
const stores = _childStores
|
||||
const enqueue = _enqueueSessionMaterialization
|
||||
if (!stores || !enqueue || !sessionId) return
|
||||
|
||||
for (const [directory, store] of stores.children) {
|
||||
const state = store.getState()
|
||||
if (
|
||||
!state.session.some((session) => session.id === sessionId)
|
||||
&& !Object.prototype.hasOwnProperty.call(state.message, sessionId)
|
||||
&& !Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId)
|
||||
&& !Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const messageID = getStaleRunningToolMessageID(state, sessionId)
|
||||
if (messageID) {
|
||||
enqueue(directory, sessionId, messageID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function removeQuestionRequestFromChildStores(sessionId: string, requestId: string): boolean {
|
||||
const stores = _childStores
|
||||
if (!stores || !requestId) return false
|
||||
@@ -1581,6 +1662,7 @@ export async function respondToQuestion(
|
||||
} catch (error) {
|
||||
if (isQuestionRequestNotFoundError(error)) {
|
||||
removeQuestionRequestFromChildStores(sessionId, requestId)
|
||||
recoverStaleBlockingRequest(sessionId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -1605,6 +1687,7 @@ export async function rejectQuestion(
|
||||
} catch (error) {
|
||||
if (isQuestionRequestNotFoundError(error)) {
|
||||
removeQuestionRequestFromChildStores(sessionId, requestId)
|
||||
recoverStaleBlockingRequest(sessionId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -629,6 +629,19 @@ async function resyncDirectorySessionStatuses(
|
||||
applySessionStatusSnapshot(store, nextStatuses, candidateSessionIds, mode)
|
||||
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.
|
||||
for (const sessionId of candidateSessionIds) {
|
||||
const interrupted = interruptedTurnToolParts(store.getState(), sessionId)
|
||||
if (interrupted) {
|
||||
store.setState((state) => ({
|
||||
part: { ...state.part, [interrupted.messageID]: interrupted.parts },
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextStatuses
|
||||
}
|
||||
@@ -1765,11 +1778,98 @@ 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.
|
||||
if (sessionID) {
|
||||
const interrupted = interruptedTurnToolParts(state, sessionID)
|
||||
if (interrupted) {
|
||||
cloneField("part", (value) => ({ ...(value ?? {}) }))
|
||||
;(draft as DirectoryStore).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 } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interrupted-turn reconciliation
|
||||
//
|
||||
// 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
|
||||
// settle-triggered tail refresh above refetches the same stale records, so
|
||||
// the UI would keep running tool timers and "working" styling 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.
|
||||
export function interruptedTurnToolParts(
|
||||
state: DirectoryStore,
|
||||
sessionID: string,
|
||||
now = Date.now(),
|
||||
): { messageID: string; parts: Part[] } | null {
|
||||
if ((state.question?.[sessionID] ?? []).length > 0) return null
|
||||
if ((state.permission?.[sessionID] ?? []).length > 0) return null
|
||||
|
||||
const status = state.session_status?.[sessionID]
|
||||
if (!status || status.type !== "idle") {
|
||||
// Absent status is "unknown", not settled (the reducer maps both
|
||||
// session.idle and session.error to {type:"idle"}): never judge an
|
||||
// interrupted turn without an authoritative settle signal.
|
||||
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") {
|
||||
// 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
|
||||
|
||||
let changed = false
|
||||
const nextParts = current.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
|
||||
return {
|
||||
...part,
|
||||
state: {
|
||||
...partState,
|
||||
status: "error",
|
||||
error: "Interrupted",
|
||||
time: { ...(partState.time ?? {}), end: now },
|
||||
},
|
||||
} as Part
|
||||
})
|
||||
return changed ? { messageID, parts: nextParts } : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2267,6 +2367,12 @@ export function SyncProvider(props: {
|
||||
props.sdk,
|
||||
childStores,
|
||||
() => opencodeClient.getDirectory() || props.directory,
|
||||
(directory, sessionID, messageID) => {
|
||||
enqueueSessionMaterialization(directory, sessionID, childStores, {
|
||||
reason: "settled-running-tool",
|
||||
messageID,
|
||||
})
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
if (getImperativeSessionMessageLoader() === messageLoader) {
|
||||
|
||||
Reference in New Issue
Block a user