Repository navigation
Reverting a message restores the whole worktree and discards other sessions' uncommitted changes #54026
Description
Activity
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):
- A session runs a step that only calls a read-only
shellcommand (ls). - 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.
- Reverting the message through
POST /session/:id/revert/stage, with nofiles(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:
- v2: redesign filesystem snapshots around mutation epochs #44511: the v2 snapshot redesign, which names this exact cause (changes from another session during a step being attributed to the assistant and overwritten by revert)
- Undo in one session reverts all sessions #33940: the same symptom reported on v1
- Session snapshot "restore/revert" bulk-overwrites the whole worktree with a stale baseline tree that is never rotated #49732, /undo destroyed 227 files, /restore destroyed 15,232 files. Several HUNDREDS OF THOUSANDS OF DOLLARS OF DAMAGE #53560, Snapshot restore from a subdirectory can overwrite sibling files #39513: other cases of snapshot restore overwriting files a session never touched
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 drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.- A session runs a step that only calls a read-only
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
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=serverlocal). Those mtimes have since been overwritten by restoring the files.
POST /api/session/:sessionID/revert/stage,POST /api/session/:sessionID/revert/commit,DELETE /api/session/:sessionID/revert. The stage endpoint accepts anoptional
fileslist; the client call passed none, so the restore wasunscoped.
msg_11c7818fc0012kExKO5arsoAsj) is no longer presentin the session store after the revert.
agent=plan, andno
edit/writetool appears in its stored messages. It did runshellandsubagenttools, so whether the reverted message itself wrote anything couldnot be confirmed — but the unrelated files it rolled back prove the restore
was not scoped to that message.
Plugins
opencode-mem
OpenCode version
2.0.23
Steps to reproduce
client UI so it does not pollute the context.
Screenshot and/or share link
No response
Operating System
Linux 6.1.0-32-amd64
Terminal
OpenChamber web UI (@openchamber/web)