opencode2 mutates shared V1 database and breaks opencode 1.x coexistence · Issue #42260 · anomalyco/opencode · GitHub
Skip to content

opencode2 mutates shared V1 database and breaks opencode 1.x coexistence #42260

Closed

Description

@timrichardson

Summary

this is almost entirely a Sol generated ticket. Not this line. It's not very good at capturing human pain.
opencode2 migrated the database schema, and now in opencode the /move command is broken, I have a session trapped in a worktree.

  • /move fails because session_context_epoch is missing.
  • Workspace loading fails because workspace.project_id is missing.
  • The database is ~/.local/share/opencode/opencode.db (2.3 GB).
  • SQLite reports integrity_check: ok; this is schema drift, not file corruption.

opencode2 and stable opencode use the same default SQLite database, ~/.local/share/opencode/opencode.db. Starting opencode2 applies an incompatible V2 schema to that file. Stable opencode 1.x then fails in normal UI paths, including /move.

The two executables have distinct names and are expected to be runnable side by side, but the V2 preview currently has no default data/database isolation and silently breaks the stable installation's database contract.

This is related to #38471, but the trigger here is the separately named opencode2 preview executable migrating the stable CLI's database, rather than Desktop auto-updating while an older CLI remains on PATH.

Environment

  • Stable CLI: opencode 1.18.18
  • Preview CLI/service: opencode2 v0.0.0-next-17403
  • The service registration showed the active background service started on 0.0.0-next-17379 and then updated to next-17403
  • OS: Linux 7.0.14-wqfix x86_64
  • Shared database: ~/.local/share/opencode/opencode.db

Steps to reproduce

  1. Install and use stable opencode 1.18.18 so it has existing sessions in:

    ~/.local/share/opencode/opencode.db
    
  2. Install/start opencode2 using its default paths. Its detached service opens the same database and performs V1/V2 migration work.

  3. Start stable OpenCode with diagnostics:

    opencode --print-logs --log-level DEBUG -c
  4. In stable OpenCode, move the active session to another existing worktree:

    /move /path/to/another/worktree
    
  5. Observe the TUI popup:

    Unexpected server error. Check server logs for details.
    

Actual result

Stable opencode fails because the shared database now has the V2 schema.

The /move failure is:

SQLiteError: no such table: session_context_epoch
  at SessionContextEpoch.reset
  at MoveSession.moveSession
  at ControlPlaneHttpApi.moveSession

Startup/listing also fails with:

SQLiteError: no such column: workspace.project_id
  at Workspace.list

Read-only schema inspection after opencode2 ran showed:

PRAGMA table_info(workspace);

returned only:

id, provider, binding, created_at, last_used_at

and session_context_epoch no longer existed. Those are consistent with the V2 schema but incompatible with opencode 1.18.18.

Expected result

opencode2 should not mutate the stable CLI's database by default. The separately named preview executable should use an isolated data/state namespace, for example:

~/.local/share/opencode2/
~/.local/state/opencode2/
~/.cache/opencode2/
~/.config/opencode2/

or at minimum a separately named database and service registration.

If sharing/migrating the stable database is intentional, opencode2 should require explicit opt-in, preserve a compatible copy, or block with a clear warning that stable opencode will no longer be usable against that database.

Impact

  • Stable opencode is broken after running the V2 preview.
  • Existing session history is placed at risk because deleting/recreating the DB loses it.
  • Running both binaries concurrently can leave processes with incompatible code operating on the same database.
  • The generic TUI error hides that opencode2 changed the schema.

Workaround

  1. repair DB (I'll attach a script)
  2. start as something like this
OPENCODE_DB="$HOME/.local/share/opencode/opencode-v1.db" \
OPENCODE_DISABLE_AUTOUPDATE=1 \
"$HOME/.opencode/bin/opencode"

.

Activity

  1. dajiaohuang commented on Aug 13, 2026

    @dajiaohuang

    The report confirms that opencode2 currently opens and migrates the same database as stable 1.x, but the repository docs and V2 specs do not state whether side-by-side use is supported.

    Is the intended contract that preview and stable may coexist? If yes, migrations need to preserve 1.x readability (or use a compatible copy); if no, should opencode2 isolate its data/state namespace by default and warn before touching an existing stable database? That product/compatibility decision determines the correct fix and regression tests.

  2. timrichardson commented on Aug 13, 2026

    @timrichardson
    SponsorAuthor

    Experimental one-time recovery script and coexistence workaround

    I used the following one-time script to reconstruct a separate OpenCode 1.x database from the surviving legacy tables. It was tested specifically with opencode 1.18.18.

    Safety notes:

    • Back up the original database and its -wal/-shm files before recovery.
    • exit all sessions of opencode/opencode2
    • The source database is opened read-only and copied through SQLite backup.
    • Account credentials and session_share.secret authorization values are excluded by default; use --include-credentials only if explicitly required.
    • The output is created with mode 0600, validated, and never overwrites an existing path.
    • The repaired database still contains private session history and local paths. Do not upload it.
    repair_opencode_v1.py
    #!/usr/bin/env python3
    """Build an isolated OpenCode 1.x database from the surviving legacy data."""
    
    from __future__ import annotations
    
    import argparse
    import os
    from pathlib import Path
    import sqlite3
    import subprocess
    import tempfile
    
    
    CREDENTIAL_TABLES = (
        "account",
        "account_state",
        "control_account",
        "credential",
        "session_share",
    )
    
    DATA_TABLES = (
        "event_sequence",
        "event",
        "project",
        "project_directory",
        "permission",
        "session",
        "message",
        "part",
        "todo",
        "session_message",
    )
    
    
    def arguments() -> argparse.Namespace:
        parser = argparse.ArgumentParser(
            description=(
                "Create a fresh OpenCode 1.x database and copy compatible legacy "
                "records from a database migrated by opencode2."
            )
        )
        parser.add_argument(
            "--source",
            type=Path,
            default=Path.home() / ".local/share/opencode/opencode.db",
            help="database currently used by opencode2",
        )
        parser.add_argument(
            "--output",
            type=Path,
            default=Path.home() / ".local/share/opencode/opencode-v1.db",
            help="new database for stable opencode",
        )
        parser.add_argument(
            "--opencode",
            type=Path,
            default=Path.home() / ".opencode/bin/opencode",
            help="stable OpenCode 1.x executable",
        )
        parser.add_argument(
            "--expected-version",
            default="1.18.18",
            help="exact stable OpenCode version tested for the destination schema",
        )
        parser.add_argument(
            "--include-credentials",
            action="store_true",
            help=(
                "copy account, credential, and session-share authorization tables; by default "
                "these tables remain empty and OpenCode must be reauthenticated"
            ),
        )
        return parser.parse_args()
    
    
    def quote(identifier: str) -> str:
        return '"' + identifier.replace('"', '""') + '"'
    
    
    def columns(connection: sqlite3.Connection, schema: str, table: str) -> list[sqlite3.Row]:
        return connection.execute(
            f"PRAGMA {quote(schema)}.table_info({quote(table)})"
        ).fetchall()
    
    
    def copy_table(connection: sqlite3.Connection, table: str) -> int:
        source_columns = {row["name"] for row in columns(connection, "source", table)}
        destination_columns = columns(connection, "main", table)
        if not source_columns or not destination_columns:
            raise RuntimeError(f"required table is missing: {table}")
    
        missing_required = [
            row["name"]
            for row in destination_columns
            if row["name"] not in source_columns
            and row["notnull"]
            and row["dflt_value"] is None
            and not row["pk"]
        ]
        if missing_required:
            raise RuntimeError(
                f"source {table} lacks required destination columns: {missing_required}"
            )
    
        names = [row["name"] for row in destination_columns if row["name"] in source_columns]
        selected = ", ".join(quote(name) for name in names)
        where = ""
        if table == "session_message":
            # opencode2 reused this table for session_v2. Only legacy session rows
            # satisfy the foreign key contract of the stable OpenCode schema.
            where = (
                " WHERE EXISTS (SELECT 1 FROM source.session "
                "WHERE source.session.id = source.session_message.session_id)"
            )
        connection.execute(
            f"INSERT INTO main.{quote(table)} ({selected}) "
            f"SELECT {selected} FROM source.{quote(table)}{where}"
        )
        return connection.execute(
            f"SELECT COUNT(*) FROM main.{quote(table)}"
        ).fetchone()[0]
    
    
    def executable_version(executable: Path) -> str:
        result = subprocess.run(
            [str(executable), "--version"],
            check=True,
            capture_output=True,
            text=True,
        )
        return result.stdout.strip()
    
    
    def initialize_database(executable: Path, database: Path, profile: Path) -> None:
        environment = os.environ.copy()
        environment["OPENCODE_DB"] = str(database)
        environment["OPENCODE_DISABLE_AUTOUPDATE"] = "1"
        environment["HOME"] = str(profile / "home")
        environment["XDG_DATA_HOME"] = str(profile / "data")
        environment["XDG_STATE_HOME"] = str(profile / "state")
        environment["XDG_CACHE_HOME"] = str(profile / "cache")
        environment["XDG_CONFIG_HOME"] = str(profile / "config")
        environment["TMPDIR"] = str(profile / "tmp")
        for directory in (
            Path(environment["HOME"]),
            Path(environment["XDG_DATA_HOME"]),
            Path(environment["XDG_STATE_HOME"]),
            Path(environment["XDG_CACHE_HOME"]),
            Path(environment["XDG_CONFIG_HOME"]),
            Path(environment["TMPDIR"]),
        ):
            directory.mkdir(parents=True, mode=0o700)
        result = subprocess.run(
            [str(executable), "db", "path"],
            env=environment,
            check=True,
            capture_output=True,
            text=True,
        )
        if Path(result.stdout.strip()).resolve() != database.resolve():
            raise RuntimeError("stable opencode initialized an unexpected database path")
        os.chmod(database, 0o600)
    
    
    def fsync_file(path: Path) -> None:
        descriptor = os.open(path, os.O_RDONLY)
        try:
            os.fsync(descriptor)
        finally:
            os.close(descriptor)
    
    
    def fsync_directory(path: Path) -> None:
        descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
        try:
            os.fsync(descriptor)
        finally:
            os.close(descriptor)
    
    
    def publish_no_clobber(repaired: Path, output: Path) -> None:
        """Publish a validated same-filesystem database without replacing any entry."""
        try:
            os.link(repaired, output, follow_symlinks=False)
        except FileExistsError:
            raise FileExistsError(f"output already exists: {output}") from None
        try:
            os.chmod(output, 0o600, follow_symlinks=False)
            fsync_file(output)
            fsync_directory(output.parent)
        except Exception:
            output.unlink(missing_ok=True)
            fsync_directory(output.parent)
            raise
    
    
    def main() -> int:
        os.umask(0o077)
        args = arguments()
        source = args.source.expanduser().resolve()
        requested_output = args.output.expanduser()
        output_parent = requested_output.parent.resolve()
        output = output_parent / requested_output.name
        executable = args.opencode.expanduser().resolve()
    
        if not source.is_file():
            raise FileNotFoundError(source)
        if not executable.is_file():
            raise FileNotFoundError(executable)
        if not args.expected_version.strip():
            raise ValueError("--expected-version must not be empty")
        actual_version = executable_version(executable)
        if actual_version != args.expected_version:
            raise RuntimeError(
                f"OpenCode version mismatch: expected {args.expected_version!r}, got {actual_version!r}"
            )
    
        output.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        if os.path.lexists(output):
            raise FileExistsError(f"output already exists: {output}")
        if source == output:
            raise ValueError("source and output must be different paths")
    
        with tempfile.TemporaryDirectory(prefix="opencode-v1-repair-", dir=output.parent) as temp:
            temporary = Path(temp)
            snapshot = temporary / "source-snapshot.db"
            repaired = temporary / "repaired.db"
    
            print(f"Creating consistent source snapshot: {snapshot}")
            with sqlite3.connect(source.as_uri() + "?mode=ro", uri=True) as source_db:
                with sqlite3.connect(snapshot) as snapshot_db:
                    source_db.backup(snapshot_db)
    
            print(f"Initializing fresh schema with: {executable}")
            initialize_database(executable, repaired, temporary / "isolated-profile")
    
            counts = {}
            with sqlite3.connect(repaired) as database:
                database.row_factory = sqlite3.Row
                database.execute("PRAGMA foreign_keys = OFF")
                database.execute("ATTACH DATABASE ? AS source", (str(snapshot),))
                database.execute("BEGIN IMMEDIATE")
                try:
                    copy_order = (*CREDENTIAL_TABLES, *DATA_TABLES) if args.include_credentials else DATA_TABLES
                    for table in copy_order:
                        counts[table] = copy_table(database, table)
                    database.commit()
                except Exception:
                    database.rollback()
                    raise
                finally:
                    database.execute("DETACH DATABASE source")
    
                database.execute("PRAGMA foreign_keys = ON")
                foreign_keys = database.execute("PRAGMA foreign_key_check").fetchall()
                integrity = database.execute("PRAGMA integrity_check").fetchone()[0]
                if foreign_keys:
                    raise RuntimeError(f"foreign-key violations found: {len(foreign_keys)}")
                if integrity != "ok":
                    raise RuntimeError(f"integrity check failed: {integrity}")
    
                if not args.include_credentials:
                    credential_counts = {
                        table: database.execute(
                            f"SELECT COUNT(*) FROM main.{quote(table)}"
                        ).fetchone()[0]
                        for table in CREDENTIAL_TABLES
                    }
                    if any(credential_counts.values()):
                        raise RuntimeError(
                            f"credential tables are not empty in default mode: {credential_counts}"
                        )
    
                checkpoint = database.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
                if checkpoint is None or checkpoint[0] != 0 or checkpoint[1] != checkpoint[2]:
                    raise RuntimeError(f"WAL checkpoint did not complete: {checkpoint}")
    
            wal = repaired.with_name(repaired.name + "-wal")
            if wal.exists() and wal.stat().st_size:
                raise RuntimeError(f"non-empty WAL remains after checkpoint: {wal}")
            os.chmod(repaired, 0o600)
            fsync_file(repaired)
            publish_no_clobber(repaired, output)
            print(f"Created repaired database: {output}")
            print(f"OpenCode version: {actual_version}")
            print("Permissions: 0600")
            if args.include_credentials:
                print("Credential tables: copied; keep this database private")
            else:
                print("Credential tables: excluded; reauthenticate stable OpenCode")
            for table, count in counts.items():
                print(f"  {table}: {count}")
            print("Validation: integrity_check=ok, foreign_key_check=ok")
            print("Warning: the database contains private session history and local paths; do not share it.")
            print("Launch stable OpenCode with:")
            print(f"  OPENCODE_DB={output} OPENCODE_DISABLE_AUTOUPDATE=1 {executable}")
        return 0
    
    
    if __name__ == "__main__":
        raise SystemExit(main())

    Example:

    python repair_opencode_v1.py \
      --source "$HOME/.local/share/opencode/opencode.db" \
      --output "$HOME/.local/share/opencode/opencode-v1.db" \
      --opencode "$HOME/.opencode/bin/opencode" \
      --expected-version 1.18.18

    Run stable V1 against only its repaired database on every invocation:

    OPENCODE_DB="$HOME/.local/share/opencode/opencode-v1.db" \
    OPENCODE_DISABLE_AUTOUPDATE=1 \
    "$HOME/.opencode/bin/opencode"

    For stronger ongoing coexistence, run every opencode2 invocation, including service management, under a separate XDG profile:

    profile="$HOME/.local/opencode2-profile"
    export XDG_DATA_HOME="$profile/data"
    export XDG_STATE_HOME="$profile/state"
    export XDG_CACHE_HOME="$profile/cache"
    export XDG_CONFIG_HOME="$profile/config"
    export TMPDIR="$profile/tmp"
    exec /path/to/real/opencode2 "$@"

    This gives V2 its own database, service registration, logs, cache, configuration, and temporary files instead of sharing V1's defaults.

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions