Skip to content

Reverting a message restores the whole worktree and discards other sessions' uncommitted changes #54026

Description

@Pyrdon

Description

Summary

Reverting a message restores the entire shared working tree to the snapshot
taken at that message, silently discarding uncommitted changes made by other
sessions. The revert is not scoped to the files the reverted message changed.

Expected Behavior

Reverting a message should only restore the files that message (and any later
ones) changed. It should not touch files the message never wrote, and it should
warn before discarding uncommitted changes from other sessions.

Actual Behavior

The whole working tree was restored to the snapshot at the reverted message.
Six files unrelated to the reverted message were overwritten (undoing Session A's
edits) and a test file was deleted. No warning was shown.

Additional Context

  • Server log (UTC; local is +02:00):
    timestamp=2026-10-08T21:25:40.821Z level=INFO run=0b819a15 message=session.revert.stage sessionID=ses_f11c69a3fffesDjJhY4qNeB0KY messageID=msg_11c7818fc0012kExKO5arsoAsj files=undefined http.span=1 role=server
  • The overwritten files' mtimes fell ~0.1s after that line (23:25:40.9-41.1
    local). Those mtimes have since been overwritten by restoring the files.
  • Revert API surface: POST /api/session/:sessionID/revert/stage,
    POST /api/session/:sessionID/revert/commit,
    DELETE /api/session/:sessionID/revert. The stage endpoint accepts an
    optional files list; the client call passed none, so the restore was
    unscoped.
  • The reverted message id (msg_11c7818fc0012kExKO5arsoAsj) is no longer present
    in the session store after the revert.
  • Session B is a plan session; every assistant reply in it is agent=plan, and
    no edit/write tool appears in its stored messages. It did run shell and
    subagent tools, so whether the reverted message itself wrote anything could
    not be confirmed — but the unrelated files it rolled back prove the restore
    was not scoped to that message.
  • Frequency: observed once, but the mechanism is deterministic.

Plugins

opencode-mem

OpenCode version

2.0.23

Steps to reproduce

  1. Open two sessions on the same repository/worktree:
    • Session A: working normally, with uncommitted edits.
    • Session B: a plan-mode session.
  2. In Session B, send a message by mistake, then revert that message via the
    client UI so it does not pollute the context.
  3. Inspect Session A's working tree.

Screenshot and/or share link

No response

Operating System

Linux 6.1.0-32-amd64

Terminal

OpenChamber web UI (@openchamber/web)

Activity

  1. opencode-agent commented on Oct 8, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I was able to reproduce this on 2.0.23, and it still happens on the latest v2 (b5c43a44).

    What I ran (script below):

    1. A session runs a step that only calls a read-only shell command (ls).
    2. While that step is running, something else (here a separate writer standing in for your Session A) changes a tracked file and creates a new file in the same worktree.
    3. Reverting the message through POST /session/:id/revert/stage, with no files (the same call the web UI makes), puts the edited file back to its old content and deletes the new file, with no warning.

    The revert isn't restoring the whole worktree. It only restores the files recorded for each step. The problem is that this file list comes from comparing snapshots of the whole worktree taken at the start and end of the step, so anything another session changed during that step gets counted as this session's change and is then rolled back.

    Related issues:

    Until this is fixed, the safest option is not to revert in one session while another session is editing the same worktree, or to give each session its own git worktree.

    Reproduction script (drive.ts)
    // Repro for #54026: reverting a message in session B rolls back edits that
    // another session (or any writer) made to the shared worktree while B's step ran.
    import { Effect } from "effect"
    import * as fs from "node:fs"
    import * as path from "node:path"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        project: {
          git: true,
          files: {
            "a.txt": "session A original\n",
            "untouched.txt": "never changed\n",
          },
        },
        // Drive disables snapshots by default; OpenCode enables them by default.
        config: { snapshots: true } as any,
        tools: ["shell"],
        tui: { recording: true },
      },
      ({ ui, llm, tools, opencode, artifacts }) =>
        Effect.gen(function* () {
          const dir = path.join(artifacts, "files")
          const shells = yield* tools.control("shell")
    
          // Session B: a step that only runs a read-only shell command.
          yield* llm.queue(
            Llm.toolCall({ index: 0, id: "call_ls", name: "shell", input: { command: "ls", description: "list" } }),
            Llm.finish("tool-calls"),
          )
          yield* llm.queue(Llm.text("Done listing files."))
          yield* ui.submit("list the files (sent by mistake)")
          const shell = yield* shells.take("call_ls")
    
          // Meanwhile "session A" edits a tracked file and creates a new one.
          fs.writeFileSync(path.join(dir, "a.txt"), "session A EDITED (uncommitted work)\n")
          fs.writeFileSync(path.join(dir, "a.test.ts"), "// new test written by session A\n")
          yield* Effect.log("before step end: a.txt=" + JSON.stringify(fs.readFileSync(path.join(dir, "a.txt"), "utf8")))
    
          yield* shell.succeed({ output: "a.txt\nuntouched.txt\n", exit: 0 })
          yield* ui.waitFor("Done listing files.")
          yield* Effect.sleep(1000)
    
          const sessions: any = yield* opencode.session.list()
          const session = (sessions.items ?? sessions.data ?? sessions)[0]
          const messages: any = yield* opencode.message.list({ sessionID: session.id } as any)
          const list: any[] = messages.items ?? messages.data ?? messages
          for (const m of list) yield* Effect.log(`msg ${m.id} ${m.type} snapshot=${JSON.stringify(m.snapshot)}`)
          const user = list.find((m) => m.type === "user")
    
          yield* Effect.log(`before revert: a.txt=${JSON.stringify(fs.readFileSync(path.join(dir, "a.txt"), "utf8"))} a.test.ts exists=${fs.existsSync(path.join(dir, "a.test.ts"))}`)
          // Same call the web UI makes: stage a revert of the mistaken message, no `files` flag.
          const revert = yield* opencode.session.revert.stage({ sessionID: session.id, messageID: user.id } as any)
          yield* Effect.log("revert staged: " + JSON.stringify(revert))
          yield* Effect.sleep(1500)
          const after = fs.readFileSync(path.join(dir, "a.txt"), "utf8")
          const exists = fs.existsSync(path.join(dir, "a.test.ts"))
          yield* Effect.log(`AFTER revert: a.txt=${JSON.stringify(after)} a.test.ts exists=${exists}`)
          yield* ui.screenshot("after-revert")
          yield* Effect.log(after.includes("EDITED") && exists ? "RESULT: other session's work preserved" : "RESULT: BUG - other session's work discarded")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions