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
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
Install and use stable opencode 1.18.18 so it has existing sessions in:
~/.local/share/opencode/opencode.db
Install/start opencode2 using its default paths. Its detached service opens the same database and performs V1/V2 migration work.
Start stable OpenCode with diagnostics:
opencode --print-logs --log-level DEBUG -c
In stable OpenCode, move the active session to another existing worktree:
/move /path/to/another/worktree
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:
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.
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.
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__ importannotationsimportargparseimportosfrompathlibimportPathimportsqlite3importsubprocessimporttempfileCREDENTIAL_TABLES= (
"account",
"account_state",
"control_account",
"credential",
"session_share",
)
DATA_TABLES= (
"event_sequence",
"event",
"project",
"project_directory",
"permission",
"session",
"message",
"part",
"todo",
"session_message",
)
defarguments() ->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"
),
)
returnparser.parse_args()
defquote(identifier: str) ->str:
return'"'+identifier.replace('"', '""') +'"'defcolumns(connection: sqlite3.Connection, schema: str, table: str) ->list[sqlite3.Row]:
returnconnection.execute(
f"PRAGMA {quote(schema)}.table_info({quote(table)})"
).fetchall()
defcopy_table(connection: sqlite3.Connection, table: str) ->int:
source_columns= {row["name"] forrowincolumns(connection, "source", table)}
destination_columns=columns(connection, "main", table)
ifnotsource_columnsornotdestination_columns:
raiseRuntimeError(f"required table is missing: {table}")
missing_required= [
row["name"]
forrowindestination_columnsifrow["name"] notinsource_columnsandrow["notnull"]
androw["dflt_value"] isNoneandnotrow["pk"]
]
ifmissing_required:
raiseRuntimeError(
f"source {table} lacks required destination columns: {missing_required}"
)
names= [row["name"] forrowindestination_columnsifrow["name"] insource_columns]
selected=", ".join(quote(name) fornameinnames)
where=""iftable=="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}"
)
returnconnection.execute(
f"SELECT COUNT(*) FROM main.{quote(table)}"
).fetchone()[0]
defexecutable_version(executable: Path) ->str:
result=subprocess.run(
[str(executable), "--version"],
check=True,
capture_output=True,
text=True,
)
returnresult.stdout.strip()
definitialize_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")
fordirectoryin (
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,
)
ifPath(result.stdout.strip()).resolve() !=database.resolve():
raiseRuntimeError("stable opencode initialized an unexpected database path")
os.chmod(database, 0o600)
deffsync_file(path: Path) ->None:
descriptor=os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
deffsync_directory(path: Path) ->None:
descriptor=os.open(path, os.O_RDONLY|getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
defpublish_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)
exceptFileExistsError:
raiseFileExistsError(f"output already exists: {output}") fromNonetry:
os.chmod(output, 0o600, follow_symlinks=False)
fsync_file(output)
fsync_directory(output.parent)
exceptException:
output.unlink(missing_ok=True)
fsync_directory(output.parent)
raisedefmain() ->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.nameexecutable=args.opencode.expanduser().resolve()
ifnotsource.is_file():
raiseFileNotFoundError(source)
ifnotexecutable.is_file():
raiseFileNotFoundError(executable)
ifnotargs.expected_version.strip():
raiseValueError("--expected-version must not be empty")
actual_version=executable_version(executable)
ifactual_version!=args.expected_version:
raiseRuntimeError(
f"OpenCode version mismatch: expected {args.expected_version!r}, got {actual_version!r}"
)
output.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
ifos.path.lexists(output):
raiseFileExistsError(f"output already exists: {output}")
ifsource==output:
raiseValueError("source and output must be different paths")
withtempfile.TemporaryDirectory(prefix="opencode-v1-repair-", dir=output.parent) astemp:
temporary=Path(temp)
snapshot=temporary/"source-snapshot.db"repaired=temporary/"repaired.db"print(f"Creating consistent source snapshot: {snapshot}")
withsqlite3.connect(source.as_uri() +"?mode=ro", uri=True) assource_db:
withsqlite3.connect(snapshot) assnapshot_db:
source_db.backup(snapshot_db)
print(f"Initializing fresh schema with: {executable}")
initialize_database(executable, repaired, temporary/"isolated-profile")
counts= {}
withsqlite3.connect(repaired) asdatabase:
database.row_factory=sqlite3.Rowdatabase.execute("PRAGMA foreign_keys = OFF")
database.execute("ATTACH DATABASE ? AS source", (str(snapshot),))
database.execute("BEGIN IMMEDIATE")
try:
copy_order= (*CREDENTIAL_TABLES, *DATA_TABLES) ifargs.include_credentialselseDATA_TABLESfortableincopy_order:
counts[table] =copy_table(database, table)
database.commit()
exceptException:
database.rollback()
raisefinally:
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]
ifforeign_keys:
raiseRuntimeError(f"foreign-key violations found: {len(foreign_keys)}")
ifintegrity!="ok":
raiseRuntimeError(f"integrity check failed: {integrity}")
ifnotargs.include_credentials:
credential_counts= {
table: database.execute(
f"SELECT COUNT(*) FROM main.{quote(table)}"
).fetchone()[0]
fortableinCREDENTIAL_TABLES
}
ifany(credential_counts.values()):
raiseRuntimeError(
f"credential tables are not empty in default mode: {credential_counts}"
)
checkpoint=database.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
ifcheckpointisNoneorcheckpoint[0] !=0orcheckpoint[1] !=checkpoint[2]:
raiseRuntimeError(f"WAL checkpoint did not complete: {checkpoint}")
wal=repaired.with_name(repaired.name+"-wal")
ifwal.exists() andwal.stat().st_size:
raiseRuntimeError(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")
ifargs.include_credentials:
print("Credential tables: copied; keep this database private")
else:
print("Credential tables: excluded; reauthenticate stable OpenCode")
fortable, countincounts.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}")
return0if__name__=="__main__":
raiseSystemExit(main())
Activity
dajiaohuang commentedon Aug 13, 2026
The report confirms that
opencode2currently 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
opencode2isolate 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.timrichardson commentedon Aug 13, 2026
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:
-wal/-shmfiles before recovery.session_share.secretauthorization values are excluded by default; use--include-credentialsonly if explicitly required.0600, validated, and never overwrites an existing path.repair_opencode_v1.pyExample:
Run stable V1 against only its repaired database on every invocation:
For stronger ongoing coexistence, run every
opencode2invocation, including service management, under a separate XDG profile:This gives V2 its own database, service registration, logs, cache, configuration, and temporary files instead of sharing V1's defaults.