Skip to content

tui: a session move gets reverted by a session fetch that was already in flight #57139

Open

Description

@WilliamAGH

Description

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:

  1. Open a session whose directory was deleted, so you get "Session location unavailable".
  2. 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).
  3. 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.

Screenshot and/or share link

No response

Operating System

macOS 27.0.1

Terminal

Ghostty

Activity

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    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:

    1. 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.
    2. A family read (sync(parent, { children: true })) does the same to a child session that was moved while the read was in flight.
    3. 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.ts
    import { expect, test } from "bun:test"
    import { createRoot } from "solid-js"
    import { createData, type CreateDataInput } from "../src/solid"
    import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
    
    const base = (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,
    })
    
    function setup(handle: (url: URL) => Promise<Response>) {
      const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
      const api = OpenCode.make({
        baseUrl: "http://opencode.local",
        fetch: async (input, init) => {
          const request = input instanceof Request ? input : new Request(input, init)
          return handle(new URL(request.url))
        },
      })
      const root = createRoot((dispose) => ({
        data: createData({
          api: () => api,
          directory: "/old",
          event: {
            on: () => () => {},
            listen(h) {
              listeners.add(h)
              return () => listeners.delete(h)
            },
          },
        }),
        dispose,
      }))
      const emit = (event: OpenCodeEvent) => listeners.forEach((l) => l({ name: event.type, details: event }))
      return { ...root, emit }
    }
    
    const moved = (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" },
      }) as OpenCodeEvent
    
    test("in-flight session.get reverts a session.moved", async () => {
      const gate = Promise.withResolvers<void>()
      const s = setup(async (url) => {
        if (url.pathname.startsWith("/api/session/")) {
          await gate.promise
          return Response.json({ data: base("ses_a", "/old") }) // stale: read started before the move
        }
        return Response.json({ data: [] })
      })
      try {
        s.data.session.remember(base("ses_a", "/old"))
        s.data.session.invalidate("ses_a")
        const pending = s.data.session.sync("ses_a")
        await Bun.sleep(10)
        s.emit(moved("ses_a", { directory: "/new" }))
        console.log("after session.moved:", s.data.session.get("ses_a")?.location)
        gate.resolve()
        await pending
        console.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 () => {
      const gate = Promise.withResolvers<void>()
      const s = setup(async (url) => {
        await gate.promise
        if (url.pathname === "/api/session/ses_parent") return Response.json({ data: base("ses_parent", "/old") })
        return Response.json({ data: [base("ses_child", "/old", { parentID: "ses_parent" } as any)] })
      })
      try {
        s.data.session.remember(base("ses_parent", "/old"))
        s.data.session.remember(base("ses_child", "/old", { parentID: "ses_parent" } as any))
        const pending = s.data.session.sync("ses_parent", { children: true })
        await Bun.sleep(10)
        s.emit(moved("ses_child", { directory: "/new" }))
        console.log("child after session.moved:", s.data.session.get("ses_child")?.location)
        gate.resolve()
        await pending
        console.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", () => {
      const s = setup(async () => Response.json({ data: [] }))
      try {
        s.data.session.remember(base("ses_w", "/old", { location: { directory: "/old", workspaceID: "wrk_old" } } as any))
        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()
      }
    })

    Run it: bun repro-57139.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions