You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
tui: a session move gets reverted by a session fetch that was already in flight #57139
If a session is moved while a fetch for that same session is still in flight, the fetch wins. Its response still has the old directory, and session.sync in packages/client/src/solid/data.ts writes it over the move that already landed. The server has the new location, but the client is back on the old one. If the old directory is gone, "Session location unavailable" comes right back after you just picked a new directory.
Family reads (sync(id, { children: true })) do the same thing to child sessions.
Smaller thing in the same area: the session.moved handler writes the new location with a plain setStore, which merges, so a workspaceID from the old location sticks around after a move to a location that doesn't have one.
This is related to #55250 and #56958, which cover why the prompt shows up in the first place. This one is about it coming back after you've already moved the session.
Plugins
No response
OpenCode version
2.0.32, still on current dev
Steps to reproduce
It's timing-dependent in the app:
Open a session whose directory was deleted, so you get "Session location unavailable".
Choose directory and pick another one while the session is still loading (right after opening it, after a reconnect, or while its subagent sessions are being listed).
The session can flip back to the old directory, and the prompt shows up again.
It reproduces every time in a test that holds the session.get response until after a session.moved event and then releases it.
Thanks for the detailed report. I reproduced this on 2.0.32, and it still happens on the latest dev (367c8fb).
The script below runs the real client data store (createData) with stubbed HTTP responses and a test event bus. It covers three cases:
session.sync(id) starts and its session.get is held back. A session.moved event moves the session to /new, and the store shows /new. When the held response returns, the store goes back to /old.
A family read (sync(parent, { children: true })) does the same to a child session that was moved while the read was in flight.
Moving a session from { directory: "/old", workspaceID: "wrk_old" } to { directory: "/new" } leaves workspaceID: "wrk_old" in the store.
This is related to #56736, where the same kind of stale read brings back deleted sessions. Its open PR, #56886, might be extended to cover moves too.
There's no TUI recording because the bug is a timing race in the client store; the test pins the exact order of events instead.
Reproduction script (repro-57139.test.ts)
// Reproduction for anomalyco/opencode#57139: a session.get started before session.moved// lands afterwards and reverts the client-side location. Run from packages/client:// bun test test/repro-57139.test.tsimport{expect,test}from"bun:test"import{createRoot}from"solid-js"import{createData,typeCreateDataInput}from"../src/solid"import{OpenCode,typeOpenCodeEvent,typeSessionInfo}from"../src/promise"constbase=(id: string,directory: string,extra: Partial<SessionInfo>={}): SessionInfo=>({
id,projectID: "project",cost: 0,tokens: {input: 0,output: 0,reasoning: 0,cache: {read: 0,write: 0}},time: {created: 0,updated: 0},location: { directory },
...extra,})functionsetup(handle: (url: URL)=>Promise<Response>){constlisteners=newSet<Parameters<CreateDataInput["event"]["listen"]>[0]>()constapi=OpenCode.make({baseUrl: "http://opencode.local",fetch: async(input,init)=>{constrequest=inputinstanceofRequest ? input : newRequest(input,init)returnhandle(newURL(request.url))},})constroot=createRoot((dispose)=>({data: createData({api: ()=>api,directory: "/old",event: {on: ()=>()=>{},listen(h){listeners.add(h)return()=>listeners.delete(h)},},}),
dispose,}))constemit=(event: OpenCodeEvent)=>listeners.forEach((l)=>l({name: event.type,details: event}))return{ ...root, emit }}constmoved=(sessionID: string,location: {directory: string;workspaceID?: string},seq=2): OpenCodeEvent=>({id: `evt_move_${sessionID}`,created: 2,type: "session.moved",durable: {aggregateID: sessionID, seq,version: 1},data: { sessionID, location,projectID: "project"},})asOpenCodeEventtest("in-flight session.get reverts a session.moved",async()=>{constgate=Promise.withResolvers<void>()consts=setup(async(url)=>{if(url.pathname.startsWith("/api/session/")){awaitgate.promisereturnResponse.json({data: base("ses_a","/old")})// stale: read started before the move}returnResponse.json({data: []})})try{s.data.session.remember(base("ses_a","/old"))s.data.session.invalidate("ses_a")constpending=s.data.session.sync("ses_a")awaitBun.sleep(10)s.emit(moved("ses_a",{directory: "/new"}))console.log("after session.moved:",s.data.session.get("ses_a")?.location)gate.resolve()awaitpendingconsole.log("after stale session.get lands:",s.data.session.get("ses_a")?.location)expect(s.data.session.get("ses_a")?.location.directory).toBe("/new")}finally{s.dispose()}})test("in-flight family read reverts a moved child",async()=>{constgate=Promise.withResolvers<void>()consts=setup(async(url)=>{awaitgate.promiseif(url.pathname==="/api/session/ses_parent")returnResponse.json({data: base("ses_parent","/old")})returnResponse.json({data: [base("ses_child","/old",{parentID: "ses_parent"}asany)]})})try{s.data.session.remember(base("ses_parent","/old"))s.data.session.remember(base("ses_child","/old",{parentID: "ses_parent"}asany))constpending=s.data.session.sync("ses_parent",{children: true})awaitBun.sleep(10)s.emit(moved("ses_child",{directory: "/new"}))console.log("child after session.moved:",s.data.session.get("ses_child")?.location)gate.resolve()awaitpendingconsole.log("child after stale family read:",s.data.session.get("ses_child")?.location)expect(s.data.session.get("ses_child")?.location.directory).toBe("/new")}finally{s.dispose()}})test("session.moved drops workspaceID from the old location",()=>{consts=setup(async()=>Response.json({data: []}))try{s.data.session.remember(base("ses_w","/old",{location: {directory: "/old",workspaceID: "wrk_old"}}asany))s.emit(moved("ses_w",{directory: "/new"}))console.log("location after move to a location without workspaceID:",s.data.session.get("ses_w")?.location)expect(s.data.session.get("ses_w")?.location).toEqual({directory: "/new"})}finally{s.dispose()}})
Activity
opencode-agent commentedon Oct 19, 2026
Thanks for the detailed report. I reproduced this on 2.0.32, and it still happens on the latest
dev(367c8fb).The script below runs the real client data store (
createData) with stubbed HTTP responses and a test event bus. It covers three cases:session.sync(id)starts and itssession.getis held back. Asession.movedevent moves the session to/new, and the store shows/new. When the held response returns, the store goes back to/old.sync(parent, { children: true })) does the same to a child session that was moved while the read was in flight.{ directory: "/old", workspaceID: "wrk_old" }to{ directory: "/new" }leavesworkspaceID: "wrk_old"in the store.This is related to #56736, where the same kind of stale read brings back deleted sessions. Its open PR, #56886, might be extended to cover moves too.
There's no TUI recording because the bug is a timing race in the client store; the test pins the exact order of events instead.
Reproduction script (repro-57139.test.ts)
Run it:
bun repro-57139.test.ts