fix(worktree): stop writing worktree registration into OpenCode's storage

Creating a worktree wrote the new directory straight into OpenCode's own
project storage: the web server updated `storage/project/<id>.json` and ran an
`UPDATE project SET sandboxes` against `opencode.db` through better-sqlite3,
and the VS Code extension wrote the same JSON.

Both wrote behind the back of a running OpenCode process. OpenCode registers a
sandbox through `project.addSandbox`, which emits a project-updated event; a
direct row write emits nothing, so a worktree created while OpenCode was
running stayed unknown to it until a restart. The SQLite write also opened a
database file owned by another live process. The VS Code write was inert on top
of that: OpenCode v2 reads sandboxes from the database, not from that JSON.

Registration is not ours to perform. OpenCode records a worktree as a sandbox
itself when an instance boots for that directory, and filters entries whose
directory no longer exists when reading them back, so removal needs no
counterpart either. The only consumer on our side, the project seed in
sync/bootstrap.ts, already falls back to `project.current()` when the seed is
absent; the worktree list itself comes from git, not from sandboxes.

Reported symptom this targets: a worktree created after `openchamber restart`
never answers prompts, and restarting OpenChamber makes it work. Not reproduced
locally, so this is not confirmed as the cause.
This commit is contained in:
Bohdan Triapitsyn
2026-08-03 23:37:53 +03:00
parent 237cae16b3
commit 4c0fc25ac8
2 changed files with 12 additions and 220 deletions
+5 -100
View File
@@ -1385,74 +1385,11 @@ const loadProjectStartCommand = async (projectID: string): Promise<string> => {
}
};
const getProjectStoragePath = (projectID: string) => {
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
};
const updateProjectSandboxes = async (
projectID: string,
primaryWorktree: string,
updater: (project: {
id: string;
worktree: string;
vcs: string;
sandboxes: string[];
time: { created: number; updated: number };
}) => void
) => {
const storagePath = getProjectStoragePath(projectID);
await fs.promises.mkdir(path.dirname(storagePath), { recursive: true });
const now = Date.now();
const base = {
id: projectID,
worktree: primaryWorktree,
vcs: 'git',
sandboxes: [] as string[],
time: { created: now, updated: now },
};
const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null);
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
current.id = String(current.id || projectID);
current.worktree = String(current.worktree || primaryWorktree);
current.vcs = current.vcs || 'git';
current.sandboxes = Array.isArray(current.sandboxes)
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
const createdAt = Number(current?.time?.created);
current.time = {
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
updated: now,
};
updater(current);
current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))];
await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
};
const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
if (!project.sandboxes.includes(sandbox)) {
project.sandboxes.push(sandbox);
}
});
};
const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
});
};
// OpenCode owns its own project/sandbox registry and records a worktree as a
// sandbox itself when an instance boots for that directory. OpenChamber used to
// write that state into OpenCode's storage JSON directly, behind the back of the
// running process — and since OpenCode v2 reads sandboxes from its database, the
// JSON write did not even reach it. Registration is not ours to perform.
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
const relative = path.relative(root, target);
@@ -1477,14 +1414,6 @@ const cleanupFailedFastWorktreeCreate = async (
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
if (!isAttached) {
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
} catch (error) {
console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
}
}
if (!isInsideWorktreeRoot || isAttached) {
return;
}
@@ -1963,12 +1892,6 @@ async function attachGitWorktreeToCandidate(
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const shouldSetUpstream = Boolean(input?.setUpstream);
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
@@ -2033,12 +1956,6 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
if (input?.returnAfterDirectoryCreated === true) {
await fs.promises.mkdir(candidate.directory, { recursive: false });
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
@@ -2129,12 +2046,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
@@ -2157,12 +2068,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
}
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
+7 -120
View File
@@ -1644,94 +1644,13 @@ const loadProjectStartCommand = async (projectID) => {
}
};
const getProjectStoragePath = (projectID) => {
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
};
const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
try {
const Database = require('better-sqlite3');
const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
if (!fs.existsSync(dbPath)) return;
const db = new Database(dbPath);
try {
const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
if (!row) return;
const json = JSON.stringify(sandboxes);
db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
} finally {
db.close();
}
} catch (error) {
console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error));
}
};
const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
const storagePath = getProjectStoragePath(projectID);
await fsp.mkdir(path.dirname(storagePath), { recursive: true });
const now = Date.now();
const base = {
id: projectID,
worktree: primaryWorktree,
vcs: 'git',
sandboxes: [],
time: {
created: now,
updated: now,
},
};
const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
current.id = String(current.id || projectID);
current.worktree = String(current.worktree || primaryWorktree);
current.vcs = current.vcs || 'git';
current.sandboxes = Array.isArray(current.sandboxes)
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
const createdAt = Number(current?.time?.created);
current.time = {
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
updated: now,
};
updater(current);
current.sandboxes = [...new Set(
(Array.isArray(current.sandboxes) ? current.sandboxes : [])
.map((entry) => String(entry || '').trim())
.filter(Boolean)
)];
await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
// Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK
syncSandboxesToOpenCodeDb(projectID, current.sandboxes);
};
const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
if (!project.sandboxes.includes(sandbox)) {
project.sandboxes.push(sandbox);
}
});
};
const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
});
};
// OpenCode owns its own project/sandbox registry. It records a worktree as a
// sandbox itself when an instance boots for that directory, and filters entries
// whose directory no longer exists when reading them back. OpenChamber used to
// write that state directly into OpenCode's storage JSON and SQLite database,
// behind the back of the running process: the row changed but the server was
// never told, so a worktree created while OpenCode was running stayed unknown
// to it until a restart. Registration is not ours to perform.
const isAttachedGitWorktreeDirectory = async (directory) => {
try {
@@ -1748,14 +1667,6 @@ const cleanupFailedFastWorktreeCreate = async (context, candidate) => {
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
if (!isAttached) {
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
} catch (error) {
console.warn('Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
}
}
if (!isInsideWorktreeRoot || isAttached) {
return;
}
@@ -3940,12 +3851,6 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const shouldSetUpstream = Boolean(input?.setUpstream);
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
@@ -4005,12 +3910,6 @@ export async function createWorktree(directory, input = {}) {
if (input?.returnAfterDirectoryCreated === true) {
await fsp.mkdir(candidate.directory, { recursive: false });
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
@@ -4103,12 +4002,6 @@ export async function removeWorktree(directory, input = {}) {
await fsp.rm(targetDirectory, { recursive: true, force: true });
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
@@ -4131,12 +4024,6 @@ export async function removeWorktree(directory, input = {}) {
}
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;