fix: harden and de-slop the merged contribution batch
Follow-ups promised on merge, plus review findings on the batch itself: - chat: task-tool output now respects the 512KiB render cap; quick-open icon is visible at rest on coarse pointers and reachable by keyboard (row keydown no longer swallows inner-button Enter/Space); composer inline-code decoration drops the metric-shifting padding; a btw fork send carries only the boundary instruction, never the promotion notice - sync: cascade revert/unrevert aborts busy descendants, busy state is read from every child store at the moment of use; rule 9 documents redo clearing all descendant revert markers - electron: renderer recovery keeps memory-eviction (a valid render-process-gone reason) and both windows share one attachRendererRecovery helper - vscode: process registry is a thin re-export of the web module (provider-env-aliases precedent) with ordered register/unregister writes and an awaited close - server/cli: managed-process registry takes injectable deps (fixes the unreaped-orphans ReferenceError), corrupt settings errors name the file, getWorktrees test restores console.warn - tests: module-mock harnesses removed (AgentsSidebar, SettingsView mobile focus — behaviors stay live but uncovered, accepted trade), QuestionMarkdown asserts rendered DOM - i18n: German gains the debug-panel request keys, Japanese/German drop removed worktree keys, Ukrainian unit spacing fixed - changelog: Copilot AI Credits entries (main + VS Code)
This commit is contained in:
@@ -268,7 +268,7 @@ Rules:
|
||||
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
|
||||
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative.
|
||||
9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
|
||||
|
||||
Examples of global-store updates performed in `session-actions.ts`:
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { statu
|
||||
const sessionMessageRecords = new Map<string, Array<{ info: Message; parts: Part[] }>>()
|
||||
const failingRevertSessionIds = new Set<string>()
|
||||
const failingUnrevertSessionIds = new Set<string>()
|
||||
let afterUnrevertCall: ((sessionId: string) => void) | null = null
|
||||
let sessionDeleteError: unknown | null = null
|
||||
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
|
||||
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
|
||||
@@ -73,6 +74,7 @@ const mockSdk = {
|
||||
}),
|
||||
unrevert: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.unrevert", params })
|
||||
afterUnrevertCall?.(String(params.sessionID))
|
||||
if (failingUnrevertSessionIds.has(String(params.sessionID))) {
|
||||
return Promise.resolve({ error: { message: "rejected" }, response: { status: 500 } })
|
||||
}
|
||||
@@ -1435,6 +1437,44 @@ describe("revertToMessage passes session directory", () => {
|
||||
"root",
|
||||
])
|
||||
})
|
||||
|
||||
test("aborts a busy descendant before reverting it", async () => {
|
||||
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 } },
|
||||
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 } },
|
||||
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 } },
|
||||
] as Session[]
|
||||
const store = createStore({}, {
|
||||
session: sessions,
|
||||
message: { root: [rootMessage] },
|
||||
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
|
||||
})
|
||||
for (const id of ["busy-child", "idle-child"]) {
|
||||
sessionMessageRecords.set(id, [{
|
||||
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
|
||||
parts: [],
|
||||
}])
|
||||
}
|
||||
|
||||
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await revertToMessage("root", "root-cutoff")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["busy-child"])
|
||||
const busyAbortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
|
||||
const busyRevertIndex = replyCalls.findIndex(
|
||||
(call) => call.method === "session.revert" && call.params.sessionID === "busy-child",
|
||||
)
|
||||
expect(busyAbortIndex).toBeLessThan(busyRevertIndex)
|
||||
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
|
||||
"busy-child",
|
||||
"idle-child",
|
||||
"root",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("unrevertSession descendant cascade", () => {
|
||||
@@ -1442,6 +1482,7 @@ describe("unrevertSession descendant cascade", () => {
|
||||
replyCalls.length = 0
|
||||
sessionMessagesResult = { data: [] }
|
||||
failingUnrevertSessionIds.clear()
|
||||
afterUnrevertCall = null
|
||||
})
|
||||
|
||||
test("unreverts only marked descendants before the parent", async () => {
|
||||
@@ -1485,6 +1526,75 @@ describe("unrevertSession descendant cascade", () => {
|
||||
"root",
|
||||
])
|
||||
})
|
||||
|
||||
test("aborts a busy descendant before unreverting it", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
|
||||
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "idle-target" } },
|
||||
] as Session[]
|
||||
const store = createStore({}, {
|
||||
session: sessions,
|
||||
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
|
||||
})
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["busy-child"])
|
||||
const abortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
|
||||
const unrevertIndex = replyCalls.findIndex(
|
||||
(call) => call.method === "session.unrevert" && call.params.sessionID === "busy-child",
|
||||
)
|
||||
expect(abortIndex).toBeLessThan(unrevertIndex)
|
||||
})
|
||||
|
||||
test("treats a descendant as busy when any child store reports a non-idle status", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
|
||||
] as Session[]
|
||||
// The session list is deduped onto /tree, but the live status arrived in the
|
||||
// store for another directory.
|
||||
const treeStore = createStore({}, { session: sessions })
|
||||
const statusStore = createStore({}, { session_status: { "busy-child": { type: "busy" } } })
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(
|
||||
mockSdk as unknown as OpencodeClient,
|
||||
createChildStores([["/tree", treeStore], ["/other", statusStore]]),
|
||||
() => "/tree",
|
||||
)
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["busy-child"])
|
||||
})
|
||||
|
||||
test("aborts a descendant that turns busy after the subtree snapshot", async () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
||||
{ id: "first-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
|
||||
{ id: "second-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
|
||||
] as Session[]
|
||||
const store = createStore({}, { session: sessions, session_status: {} })
|
||||
afterUnrevertCall = (sessionId) => {
|
||||
if (sessionId !== "first-child") return
|
||||
store.getState().patch({ session_status: { "second-child": { type: "busy" } } })
|
||||
}
|
||||
|
||||
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
||||
|
||||
await unrevertSession("root")
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
||||
.toEqual(["second-child"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("dismissPermission passes directory", () => {
|
||||
|
||||
@@ -443,13 +443,40 @@ type DescendantSession = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A session's live status can live in a different child store than the one that
|
||||
* wins the directory dedup, so any store reporting a non-idle status counts.
|
||||
* Read at the moment of use: a descendant can start working after the subtree
|
||||
* snapshot was taken.
|
||||
*/
|
||||
function isSessionBusyNow(sessionId: string): boolean {
|
||||
const stores = _childStores
|
||||
if (!stores) return false
|
||||
|
||||
for (const [, store] of stores.children) {
|
||||
const status = store.getState().session_status?.[sessionId]
|
||||
if (status && status.type !== "idle") return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function abortDescendantIfBusy(sessionId: string, directory: string): Promise<void> {
|
||||
if (!isSessionBusyNow(sessionId)) return
|
||||
try {
|
||||
await sdk().session.abort({ sessionID: sessionId, directory })
|
||||
} catch {
|
||||
// ignore abort errors
|
||||
}
|
||||
}
|
||||
|
||||
function getDescendantSessions(rootId: string): DescendantSession[] {
|
||||
const stores = _childStores
|
||||
if (!stores) return []
|
||||
|
||||
const sessionsById = new Map<string, DescendantSession>()
|
||||
for (const [storeDirectory, store] of stores.children) {
|
||||
for (const session of store.getState().session) {
|
||||
const state = store.getState()
|
||||
for (const session of state.session) {
|
||||
const directory = session.directory || storeDirectory
|
||||
const current = sessionsById.get(session.id)
|
||||
if (!current || session.directory) sessionsById.set(session.id, { session, directory })
|
||||
@@ -483,6 +510,9 @@ async function fetchSessionMessages(sessionId: string, directory?: string | null
|
||||
async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise<void> {
|
||||
for (const { session, directory } of getDescendantSessions(rootId)) {
|
||||
try {
|
||||
// A running descendant would keep writing messages past the revert
|
||||
// boundary, so stop it first for the same reason the parent is aborted.
|
||||
await abortDescendantIfBusy(session.id, directory)
|
||||
const messages = await fetchSessionMessages(session.id, directory)
|
||||
// Equal timestamps belong to the reverted side of the boundary. Keeping
|
||||
// them would rely on unrelated message IDs to decide chronology.
|
||||
@@ -500,6 +530,9 @@ async function cascadeUnrevertToDescendants(rootId: string): Promise<void> {
|
||||
for (const { session, directory } of getDescendantSessions(rootId)) {
|
||||
if (!session.revert) continue
|
||||
try {
|
||||
// Same reason as the revert cascade: a running descendant keeps writing
|
||||
// messages that the unrevert would race against.
|
||||
await abortDescendantIfBusy(session.id, directory)
|
||||
const result = await sdk().session.unrevert({ sessionID: session.id, directory })
|
||||
mirrorSessionIntoLiveStores(assertSdkData(result, "session.unrevert"), directory)
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user