From 936854c0a23b6c9e82b72662b7db00f723e6e803 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 14 Aug 2026 12:20:02 +0000 Subject: [PATCH 1/5] feat(opencode-plugin): make the git return path awaitable and guard sandbox deletion OpenCode dispatches the plugin event hook without awaiting it, so the git sync started on session.idle is invisible to hosts: session.idle can be observed (and the run closed) while sandbox changes are still being pulled, and sync failures only surface in the plugin log and TUI toasts. - Track in-flight syncs per session and serialize them, so overlapping idle events cannot race each other. - session.deleted: wait for any in-flight sync, then pull remaining changes from a running sandbox before deleting it. If unsynced changes cannot be pulled, deletion is aborted and the sandbox is preserved instead of destroying the only copy of the work. - Add a gitSync tool as an explicit, awaitable completion boundary: it returns only after changes are in the local repository and surfaces git failures as structured tool errors that hosts can observe. - Drain in-flight syncs in the dispose hook so a graceful shutdown does not abandon a running sync (ignored by OpenCode versions without dispose support). Fixes part of #46 Signed-off-by: Mislav Ivanda --- .../plugin/daytona/core/session-manager.ts | 41 +++++++++++++++++- .../plugin/daytona/git/session-git-manager.ts | 42 +++++++++++++++++++ .../.opencode/plugin/daytona/index.ts | 7 ++++ .../plugin/daytona/plugins/session-events.ts | 2 +- .../daytona/plugins/system-transform.ts | 1 + .../.opencode/plugin/daytona/tools.ts | 2 + .../plugin/daytona/tools/git-sync.ts | 36 ++++++++++++++++ packages/opencode-plugin/README.md | 8 ++++ 8 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 packages/opencode-plugin/.opencode/plugin/daytona/tools/git-sync.ts diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index 7520ad1..368c189 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -10,7 +10,7 @@ import { Daytona, DaytonaNotFoundError, type Sandbox } from '@daytona/sdk' import { logger } from './logger' -import type { SessionSandboxMap, SandboxInfo } from './types' +import type { SessionSandboxMap, SandboxInfo, SessionInfo } from './types' import { SessionGitManager } from '../git/session-git-manager' import { DaytonaSandboxGitManager } from '../git/sandbox-git-manager' import { ProjectDataStorage } from './project-data-storage' @@ -236,6 +236,8 @@ export class DaytonaSessionManager { * Delete the sandbox associated with the given session ID */ async deleteSandbox(sessionId: string, projectId: string): Promise { + await SessionGitManager.waitForPendingSync(sessionId) + let sandbox = this.sessionSandboxes.get(sessionId) // Read-only lookup so deleting never migrates sessions or rewrites project metadata. @@ -270,6 +272,7 @@ export class DaytonaSessionManager { // Delete the sandbox if we have a fully initialized one let deleted = false if (this.isFullyInitialized(sandbox)) { + await this.syncBeforeDelete(sessionId, sandbox, stored) logger.info(`Removing sandbox for session: ${sessionId}`) await sandbox.delete() deleted = true @@ -290,4 +293,40 @@ export class DaytonaSessionManager { return deleted } + + /** + * Pull not-yet-synced sandbox changes into the local repo before the sandbox is + * destroyed. Throws — aborting deletion so the sandbox is preserved — when unsynced + * changes cannot be pulled, since deleting would lose them permanently. A sandbox + * that is not running is deleted without being started: anything in it was either + * synced while it ran or is abandoned by the explicit delete. + */ + private async syncBeforeDelete( + sessionId: string, + sandbox: Sandbox, + stored: { worktree: string; session: SessionInfo } | undefined, + ): Promise { + const branchNumber = stored?.session.branchNumber + if (!branchNumber || !stored?.worktree) return + await sandbox.refreshData() + if (sandbox.state !== 'started') return + const sessionGit = new SessionGitManager(sandbox, this.repoPath, stored.worktree, branchNumber) + try { + await SessionGitManager.enqueueSessionSync(sessionId, () => sessionGit.autoCommitAndPull()) + } catch (err: any) { + throw new Error( + `Sandbox has changes that could not be synced to the local repository; the sandbox was not deleted. ${err?.message ?? err}`, + ) + } + } + + /** + * Read-only check for a session→sandbox mapping (memory or storage); never creates, + * migrates, or connects. + */ + hasSandbox(sessionId: string, projectId: string): boolean { + this.setProjectContext(projectId) + if (this.sessionSandboxes.has(sessionId)) return true + return this.dataStorage.findSession(sessionId) !== undefined + } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts index 8be5ea8..391cdc1 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts @@ -43,6 +43,48 @@ export class SessionGitManager { return new HostGitManager().allocateAndReserveBranchNumber(worktree, prefix) } + // In-flight syncs per session. OpenCode dispatches the `event` hook without awaiting it, + // so syncs started on session.idle are invisible to callers; tracking them here lets the + // delete path and plugin shutdown wait instead of destroying a sandbox mid-sync. + private static pendingSyncs = new Map>() + + /** + * Run `fn` after any in-flight sync for this session and track it until it settles. + * The caller of this invocation sees failures; waiters only observe completion. + */ + static enqueueSessionSync(sessionId: string, fn: () => Promise): Promise { + const prev = SessionGitManager.pendingSyncs.get(sessionId) ?? Promise.resolve() + const operation = prev.then(fn) + const stored: Promise = operation.then( + () => undefined, + () => undefined, + ) + SessionGitManager.pendingSyncs.set(sessionId, stored) + stored.then(() => { + if (SessionGitManager.pendingSyncs.get(sessionId) === stored) { + SessionGitManager.pendingSyncs.delete(sessionId) + } + }) + return operation + } + + /** Resolves when the session has no in-flight sync. Never rejects. */ + static async waitForPendingSync(sessionId: string): Promise { + let pending = SessionGitManager.pendingSyncs.get(sessionId) + while (pending) { + await pending + const next = SessionGitManager.pendingSyncs.get(sessionId) + pending = next === pending ? undefined : next + } + } + + /** Resolves when no session has an in-flight sync. Never rejects. */ + static async waitForAllPendingSyncs(): Promise { + while (SessionGitManager.pendingSyncs.size > 0) { + await Promise.all([...SessionGitManager.pendingSyncs.values()]) + } + } + private async getSshUrl(): Promise { const sshAccess = await this.sandbox.createSshAccess(10) return `ssh://${sshAccess.token}@ssh.app.daytona.io${this.repoPath}` diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts index 69d4ed6..79ae60d 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -29,6 +29,7 @@ import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' import { setLogFilePath } from './core/logger' import { DaytonaSessionManager } from './core/session-manager' +import { SessionGitManager } from './git/session-git-manager' import { toast } from './core/toast' import { customTools } from './plugins/custom-tools' import { eventHandlers } from './plugins/session-events' @@ -50,6 +51,12 @@ async function daytonaPlugin(ctx: PluginInput) { tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager, REPO_PATH), 'experimental.chat.system.transform': await systemPromptTransform(ctx, REPO_PATH), + // Awaited by OpenCode when the plugin scope closes (newer than the published Hooks + // type, ignored by older versions). Draining here keeps a graceful shutdown from + // abandoning a git sync that the unawaited `event` hook started on session.idle. + dispose: async () => { + await SessionGitManager.waitForAllPendingSyncs() + }, } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts index ca5e865..fc1fe79 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts @@ -37,7 +37,7 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: DaytonaSes const branchNumber = sessionManager.getBranchNumberForSandbox(projectId, sandbox.id) if (!branchNumber) return const sessionGit = new SessionGitManager(sandbox, repoPath, worktree, branchNumber) - const didSync = await sessionGit.autoCommitAndPull(ctx) + const didSync = await SessionGitManager.enqueueSessionSync(sessionId, () => sessionGit.autoCommitAndPull(ctx)) logger.info( `[idle] done sessionId=${sessionId} sandboxId=${sandbox.id} synced=${didSync} in ${Date.now() - start}ms`, ) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/system-transform.ts b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/system-transform.ts index 14c0724..2e2ec16 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/system-transform.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/system-transform.ts @@ -20,6 +20,7 @@ export async function systemPromptTransform(ctx: PluginInput, repoPath: string) 'Put all projects in the project directory. Do NOT try to use the current working directory of the host system.', "When executing long-running commands, use the 'background' option to run them asynchronously.", 'Before showing a preview URL, ensure the server is running in the sandbox on that port.', + 'When the user asks to sync, hand off, or finalize changes, run the gitSync tool and report its result.', ].join('\n'), ) } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts index bf9d368..fb46e5d 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts @@ -16,6 +16,7 @@ import { lsTool } from './tools/ls' import { globTool } from './tools/glob' import { grepTool } from './tools/grep' import { getPreviewURLTool } from './tools/get-preview-url' +import { gitSyncTool } from './tools/git-sync' import type { DaytonaSessionManager } from './core/session-manager' import type { PluginInput } from '@opencode-ai/plugin' @@ -37,5 +38,6 @@ export function createDaytonaTools( glob: globTool(sessionManager, projectId, worktree, pluginCtx), grep: grepTool(sessionManager, projectId, worktree, pluginCtx), getPreviewURL: getPreviewURLTool(sessionManager, projectId, worktree, pluginCtx), + gitSync: gitSyncTool(sessionManager, projectId, worktree, pluginCtx), } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/git-sync.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/git-sync.ts new file mode 100644 index 0000000..94cf2f9 --- /dev/null +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/git-sync.ts @@ -0,0 +1,36 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { PluginInput } from '@opencode-ai/plugin' +import type { ToolContext } from '@opencode-ai/plugin/tool' +import type { DaytonaSessionManager } from '../core/session-manager' +import { SessionGitManager } from '../git/session-git-manager' + +export const gitSyncTool = ( + sessionManager: DaytonaSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: + 'Commits pending changes in the Daytona sandbox and pulls them into the local opencode/N branch. Returns only after the changes are in the local repository, and fails with the git error otherwise. Use as the final step when the user asks to sync, hand off, or finalize sandbox changes.', + args: {}, + async execute(_args: {}, ctx: ToolContext) { + const sessionId = ctx.sessionID + if (!sessionManager.hasSandbox(sessionId, projectId)) { + return 'No sandbox exists for this session; nothing to sync.' + } + const sandbox = await sessionManager.getSandbox(sessionId, projectId, worktree, pluginCtx) + const branchNumber = sessionManager.getBranchNumberForSandbox(projectId, sandbox.id) + if (!branchNumber) { + return 'Git syncing is disabled for this session (no local git repository); nothing to sync.' + } + const sessionGit = new SessionGitManager(sandbox, sessionManager.repoPath, worktree, branchNumber) + const didSync = await SessionGitManager.enqueueSessionSync(sessionId, () => sessionGit.autoCommitAndPull(pluginCtx)) + return didSync + ? `Synced sandbox changes to local branch opencode/${branchNumber}.` + : 'No changes to sync; the local repository is already up to date.' + }, +}) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 24d99ed..c474eef 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -104,6 +104,14 @@ The plugin only synchronizes changes from the sandbox to your system. To pass lo > [!CAUTION] > When changes are synchronized to local `opencode` branches, any locally made changes will be overwritten. +#### Sync guarantees + +The per-turn sync runs in the background: OpenCode dispatches the `session.idle` event without waiting for plugin work, so observing that event does not mean the changes have reached your local repository yet. The plugin provides three stronger boundaries: + +- **`gitSync` tool** — commits pending sandbox changes and pulls them into the local `opencode/N` branch, returning only after they are in the local repository. Failures are returned as tool errors. Automation that needs a reliable handoff (for example, a supervisor driving OpenCode through the SDK) should ask the agent to run `gitSync` as its final step and check the tool result instead of treating `session.idle` as proof that changes have landed. +- **Session deletion** — before deleting a sandbox, the plugin waits for any in-flight sync and pulls remaining changes from a running sandbox. If unsynced changes cannot be pulled, deletion is aborted and the sandbox is preserved. A sandbox that is not running is deleted without being started: anything in it was either synced while it ran or is abandoned by the explicit delete. +- **Shutdown** — when OpenCode shuts down gracefully, it waits for the plugin to finish in-flight syncs before exiting. + ### Session to sandbox mapping The plugin keeps track of which sandbox belongs to each OpenCode project using local state files. This data is stored in a separate JSON file for each project: From 3dc92694092441359e8a1194b7b832fa594a494c Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 14 Aug 2026 15:17:04 +0000 Subject: [PATCH 2/5] fix(opencode-plugin): close review gaps in the sync lifecycle Review follow-ups on the awaitable git return path: - Pull by tip comparison, not commit creation: autoCommitAndPull now pulls whenever the sandbox HEAD differs from the local opencode/N ref. A prior sync that committed in the sandbox but failed to pull left stranded commits that the status-only check skipped forever - and the delete guard would then have destroyed them as 'no changes'. - Track the whole idle pipeline: the session.idle handler enqueues its entire operation (sandbox resolution included) synchronously, so a dispose() starting mid-resolution can no longer observe an empty queue and let the process exit before the sync even registers. - Make deletion a queue barrier: the final sync and sandbox.delete() run as one queue entry, so no sync can slot in between the last pull and destruction. - Abort deletion when the local repo is inaccessible: a missing worktree now aborts deletion with a clear message instead of silently reporting 'no changes' and destroying the only copy of unsynced work. - Bound the shutdown drain to 60s (with a warning) so a sync stalled on an unreachable sandbox cannot wedge process exit; the delete path stays unbounded on purpose - deleting mid-sync loses data. Verified with a live end-to-end run against a real sandbox: fresh edit sync, no-change convergence, stranded-commit recovery, and re-convergence all pass; typecheck clean. Signed-off-by: Mislav Ivanda --- .../plugin/daytona/core/session-manager.ts | 30 +++++++++----- .../plugin/daytona/git/host-git-manager.ts | 6 +++ .../plugin/daytona/git/sandbox-git-manager.ts | 6 +++ .../plugin/daytona/git/session-git-manager.ts | 40 +++++++++++++++---- .../.opencode/plugin/daytona/index.ts | 8 +++- .../plugin/daytona/plugins/session-events.ts | 19 +++++---- packages/opencode-plugin/README.md | 4 +- 7 files changed, 84 insertions(+), 29 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index 368c189..0714644 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -272,9 +272,15 @@ export class DaytonaSessionManager { // Delete the sandbox if we have a fully initialized one let deleted = false if (this.isFullyInitialized(sandbox)) { - await this.syncBeforeDelete(sessionId, sandbox, stored) - logger.info(`Removing sandbox for session: ${sessionId}`) - await sandbox.delete() + // Final sync and deletion run as ONE queue entry, so a sync enqueued between the + // wait above and this point is drained first, and nothing can slot in between + // pulling the last changes and destroying the sandbox. + const target = sandbox + await SessionGitManager.enqueueSessionSync(sessionId, async () => { + await this.syncBeforeDelete(target, stored) + logger.info(`Removing sandbox for session: ${sessionId}`) + await target.delete() + }) deleted = true sandboxGone = true logger.info(`Sandbox deleted successfully.`) @@ -297,22 +303,26 @@ export class DaytonaSessionManager { /** * Pull not-yet-synced sandbox changes into the local repo before the sandbox is * destroyed. Throws — aborting deletion so the sandbox is preserved — when unsynced - * changes cannot be pulled, since deleting would lose them permanently. A sandbox + * changes cannot be pulled, including when the local repository itself is no longer + * accessible (a silent skip there would destroy the only copy of the work). A sandbox * that is not running is deleted without being started: anything in it was either * synced while it ran or is abandoned by the explicit delete. + * + * Runs inside the session's sync queue; it must NOT enqueue (that would deadlock). */ - private async syncBeforeDelete( - sessionId: string, - sandbox: Sandbox, - stored: { worktree: string; session: SessionInfo } | undefined, - ): Promise { + private async syncBeforeDelete(sandbox: Sandbox, stored: { worktree: string; session: SessionInfo } | undefined) { const branchNumber = stored?.session.branchNumber if (!branchNumber || !stored?.worktree) return await sandbox.refreshData() if (sandbox.state !== 'started') return const sessionGit = new SessionGitManager(sandbox, this.repoPath, stored.worktree, branchNumber) + if (!sessionGit.hasLocalRepo()) { + throw new Error( + `Local repository at ${stored.worktree} is not accessible, so unsynced sandbox changes cannot be pulled; the sandbox was not deleted. Restore the repository or delete the sandbox from the Daytona dashboard.`, + ) + } try { - await SessionGitManager.enqueueSessionSync(sessionId, () => sessionGit.autoCommitAndPull()) + await sessionGit.autoCommitAndPull() } catch (err: any) { throw new Error( `Sandbox has changes that could not be synced to the local repository; the sandbox was not deleted. ${err?.message ?? err}`, diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index e40e3dd..d6228e9 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -167,6 +167,12 @@ export class HostGitManager { return execGit(['show-ref', '--verify', '--quiet', ref], { cwd }).ok } + /** Commit OID a local ref points at, or '' if the ref does not exist. */ + getRefOid(cwd: string, ref: string): string { + const res = execGit(['rev-parse', '--verify', '--quiet', ref], { cwd }) + return res.ok ? res.stdout.trim() : '' + } + /** * Returns a commit OID that branch refs can point at. Uses HEAD if the repo has commits, * otherwise creates and caches an empty commit (empty tree + commit). Branch refs must diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts index 29b1d37..205a6f9 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts @@ -61,6 +61,12 @@ export class DaytonaSandboxGitManager { return branch.trim() } + /** Commit OID of the sandbox HEAD, or '' on an unborn branch (no commits yet). */ + async getHeadOid(): Promise { + const oid = await this.runGitCommand('git rev-parse --verify --quiet HEAD || true') + return oid.trim() + } + async resetToRemote(branch: string): Promise { // Check out the branch the host just pushed. Using -f (not -B) checks out the // pushed commit instead of resetting the branch ref to the sandbox's current HEAD. diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts index 391cdc1..315f7c5 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts @@ -78,11 +78,29 @@ export class SessionGitManager { } } - /** Resolves when no session has an in-flight sync. Never rejects. */ - static async waitForAllPendingSyncs(): Promise { + /** + * Resolves when no session has an in-flight sync, or when `timeoutMs` elapses first + * (returns false in that case). Never rejects. The bound exists for shutdown, where a + * sync stalled on an unreachable sandbox must not wedge process exit; the delete path + * intentionally waits unbounded instead, because deleting mid-sync loses data. + */ + static async waitForAllPendingSyncs(timeoutMs?: number): Promise { + const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs while (SessionGitManager.pendingSyncs.size > 0) { - await Promise.all([...SessionGitManager.pendingSyncs.values()]) + const waits: Promise[] = [Promise.all([...SessionGitManager.pendingSyncs.values()])] + if (deadline !== undefined) { + const remaining = deadline - Date.now() + if (remaining <= 0) return false + waits.push( + new Promise((resolve) => { + setTimeout(resolve, remaining).unref() + }), + ) + } + await Promise.race(waits) + if (deadline !== undefined && Date.now() >= deadline && SessionGitManager.pendingSyncs.size > 0) return false } + return true } private async getSshUrl(): Promise { @@ -90,6 +108,10 @@ export class SessionGitManager { return `ssh://${sshAccess.token}@ssh.app.daytona.io${this.repoPath}` } + hasLocalRepo(): boolean { + return this.hostGit.hasRepo(this.worktree) + } + /** * Initialize git in the sandbox and sync with host * Used when a new sandbox is created for a session @@ -145,10 +167,14 @@ export class SessionGitManager { } await this.sandboxGit.ensureRepo() - const hasChanges = await this.sandboxGit.autoCommit() - - // Only sync and notify if there were actual changes - if (!hasChanges) { + await this.sandboxGit.autoCommit() + + // Pull whenever the sandbox tip differs from the local opencode/N ref, not only + // when this call created a commit: a previous sync may have committed in the + // sandbox and then failed to pull, and a status-only check would skip those + // stranded commits forever (and let the delete path destroy them). + const sandboxHead = await this.sandboxGit.getHeadOid() + if (!sandboxHead || sandboxHead === this.hostGit.getRefOid(this.worktree, `refs/heads/${this.localBranch}`)) { return false } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts index 79ae60d..110cf90 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -27,7 +27,7 @@ import { join } from 'path' import { homedir } from 'os' import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' -import { setLogFilePath } from './core/logger' +import { logger, setLogFilePath } from './core/logger' import { DaytonaSessionManager } from './core/session-manager' import { SessionGitManager } from './git/session-git-manager' import { toast } from './core/toast' @@ -54,8 +54,12 @@ async function daytonaPlugin(ctx: PluginInput) { // Awaited by OpenCode when the plugin scope closes (newer than the published Hooks // type, ignored by older versions). Draining here keeps a graceful shutdown from // abandoning a git sync that the unawaited `event` hook started on session.idle. + // Bounded so a sync stalled on an unreachable sandbox cannot wedge process exit. dispose: async () => { - await SessionGitManager.waitForAllPendingSyncs() + const drained = await SessionGitManager.waitForAllPendingSyncs(60_000) + if (!drained) { + logger.warn('[dispose] exiting with git syncs still pending after 60s; a sync may be stalled') + } }, } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts index fc1fe79..b4edef6 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts @@ -33,14 +33,17 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: DaytonaSes const sessionId = event.properties.sessionID const start = Date.now() try { - const sandbox = await sessionManager.getSandbox(sessionId, projectId, worktree, ctx) - const branchNumber = sessionManager.getBranchNumberForSandbox(projectId, sandbox.id) - if (!branchNumber) return - const sessionGit = new SessionGitManager(sandbox, repoPath, worktree, branchNumber) - const didSync = await SessionGitManager.enqueueSessionSync(sessionId, () => sessionGit.autoCommitAndPull(ctx)) - logger.info( - `[idle] done sessionId=${sessionId} sandboxId=${sandbox.id} synced=${didSync} in ${Date.now() - start}ms`, - ) + // The WHOLE pipeline is enqueued (synchronously, before any await) so that a + // dispose() or delete arriving while the sandbox is still being resolved cannot + // observe an empty queue and proceed mid-operation. + const didSync = await SessionGitManager.enqueueSessionSync(sessionId, async () => { + const sandbox = await sessionManager.getSandbox(sessionId, projectId, worktree, ctx) + const branchNumber = sessionManager.getBranchNumberForSandbox(projectId, sandbox.id) + if (!branchNumber) return false + const sessionGit = new SessionGitManager(sandbox, repoPath, worktree, branchNumber) + return sessionGit.autoCommitAndPull(ctx) + }) + logger.info(`[idle] done sessionId=${sessionId} synced=${didSync} in ${Date.now() - start}ms`) } catch (err: any) { // autoCommitAndPull already shows a toast; only log here to avoid a duplicate // error toast and noisy propagation out of the idle event hook. diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index c474eef..7e4431d 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -109,8 +109,8 @@ The plugin only synchronizes changes from the sandbox to your system. To pass lo The per-turn sync runs in the background: OpenCode dispatches the `session.idle` event without waiting for plugin work, so observing that event does not mean the changes have reached your local repository yet. The plugin provides three stronger boundaries: - **`gitSync` tool** — commits pending sandbox changes and pulls them into the local `opencode/N` branch, returning only after they are in the local repository. Failures are returned as tool errors. Automation that needs a reliable handoff (for example, a supervisor driving OpenCode through the SDK) should ask the agent to run `gitSync` as its final step and check the tool result instead of treating `session.idle` as proof that changes have landed. -- **Session deletion** — before deleting a sandbox, the plugin waits for any in-flight sync and pulls remaining changes from a running sandbox. If unsynced changes cannot be pulled, deletion is aborted and the sandbox is preserved. A sandbox that is not running is deleted without being started: anything in it was either synced while it ran or is abandoned by the explicit delete. -- **Shutdown** — when OpenCode shuts down gracefully, it waits for the plugin to finish in-flight syncs before exiting. +- **Session deletion** — before deleting a sandbox, the plugin waits for any in-flight sync and pulls remaining changes from a running sandbox. If unsynced changes cannot be pulled — including when the local repository is no longer accessible — deletion is aborted and the sandbox is preserved. A sandbox that is not running is deleted without being started: anything in it was either synced while it ran or is abandoned by the explicit delete. +- **Shutdown** — when OpenCode shuts down gracefully, it waits (up to 60 seconds) for the plugin to finish in-flight syncs before exiting. ### Session to sandbox mapping From 2734673d140609c9ca437a57c33caa895984d5d8 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 14 Aug 2026 15:35:17 +0000 Subject: [PATCH 3/5] fix(opencode-plugin): tombstone deleted sessions and fail closed on sandbox git errors Second review round on the sync lifecycle: - Tombstone deleting/deleted sessions: getSandbox creates sandboxes on demand, so a sync queued behind a deletion (or any late event for a deleted session) would have resurrected a fresh sandbox that nothing tracks or cleans up. deleteSandbox now records the session before teardown (removed again on failure), getSandbox refuses tombstoned sessions, hasSandbox reports them as absent, and the idle pipeline re-checks inside its queue entry. - getHeadOid distinguishes an unborn HEAD from git failures: it returned '' for both, and callers treat '' as nothing-to-pull, so a git error during the delete-path check could have destroyed unsynced commits. Unborn still yields ''; any other failure now propagates and aborts deletion. Live end-to-end re-run against a real sandbox: unborn-repo no-op, fresh edit sync, tip convergence, stranded-commit recovery all pass; typecheck clean. Signed-off-by: Mislav Ivanda --- .../plugin/daytona/core/session-manager.ts | 25 +++++++++++++++++++ .../plugin/daytona/git/sandbox-git-manager.ts | 13 +++++++--- .../plugin/daytona/plugins/session-events.ts | 3 +++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index 0714644..6979fcf 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -21,6 +21,10 @@ export class DaytonaSessionManager { private readonly apiKey: string private readonly dataStorage: ProjectDataStorage private sessionSandboxes: SessionSandboxMap + // Sessions whose sandbox teardown has begun. getSandbox creates sandboxes on demand, + // so without this tombstone a sync queued behind a deletion would resurrect a fresh + // sandbox for a session that no longer exists (invisible, billed, never cleaned up). + private readonly deletingSessions = new Set() private currentProjectId?: string public readonly repoPath: string @@ -83,6 +87,9 @@ export class DaytonaSessionManager { if (pluginCtx?.client?.tui) { toast.initialize(pluginCtx.client.tui) } + if (this.deletingSessions.has(sessionId)) { + throw new Error(`Session ${sessionId} is deleted; not creating a new sandbox for it.`) + } if (!this.apiKey) { logger.error('DAYTONA_API_KEY is not set. Cannot create or retrieve sandbox.') toast.show({ @@ -236,6 +243,19 @@ export class DaytonaSessionManager { * Delete the sandbox associated with the given session ID */ async deleteSandbox(sessionId: string, projectId: string): Promise { + // Tombstone first, removed again on failure: while set, no code path may create or + // reconnect a sandbox for this session. Kept after success on purpose - the session + // is gone, and any late event for it must no-op instead of resurrecting a sandbox. + this.deletingSessions.add(sessionId) + try { + return await this.deleteSandboxInner(sessionId, projectId) + } catch (err) { + this.deletingSessions.delete(sessionId) + throw err + } + } + + private async deleteSandboxInner(sessionId: string, projectId: string): Promise { await SessionGitManager.waitForPendingSync(sessionId) let sandbox = this.sessionSandboxes.get(sessionId) @@ -335,8 +355,13 @@ export class DaytonaSessionManager { * migrates, or connects. */ hasSandbox(sessionId: string, projectId: string): boolean { + if (this.deletingSessions.has(sessionId)) return false this.setProjectContext(projectId) if (this.sessionSandboxes.has(sessionId)) return true return this.dataStorage.findSession(sessionId) !== undefined } + + isSessionDeleting(sessionId: string): boolean { + return this.deletingSessions.has(sessionId) + } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts index 205a6f9..4b3318c 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts @@ -61,10 +61,17 @@ export class DaytonaSandboxGitManager { return branch.trim() } - /** Commit OID of the sandbox HEAD, or '' on an unborn branch (no commits yet). */ + /** + * Commit OID of the sandbox HEAD, or '' on an unborn branch (no commits yet). + * Any other git failure throws: callers use '' as "nothing to pull", and the delete + * path acts on it, so a masked error here could destroy unsynced commits. + */ async getHeadOid(): Promise { - const oid = await this.runGitCommand('git rev-parse --verify --quiet HEAD || true') - return oid.trim() + const out = await this.runGitCommand( + 'if oid=$(git rev-parse --verify --quiet HEAD); then echo "$oid"; else git rev-parse --is-inside-work-tree > /dev/null && echo UNBORN; fi', + ) + const trimmed = out.trim() + return trimmed === 'UNBORN' ? '' : trimmed } async resetToRemote(branch: string): Promise { diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts index b4edef6..141b46d 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts @@ -37,6 +37,9 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: DaytonaSes // dispose() or delete arriving while the sandbox is still being resolved cannot // observe an empty queue and proceed mid-operation. const didSync = await SessionGitManager.enqueueSessionSync(sessionId, async () => { + // Re-checked inside the queue entry: a deletion may have completed while this + // callback waited its turn, and syncing must not resurrect the sandbox. + if (sessionManager.isSessionDeleting(sessionId)) return false const sandbox = await sessionManager.getSandbox(sessionId, projectId, worktree, ctx) const branchNumber = sessionManager.getBranchNumberForSandbox(projectId, sandbox.id) if (!branchNumber) return false From bf1a403b2c476c17849eef7494aff1ea97677f44 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 14 Aug 2026 15:45:30 +0000 Subject: [PATCH 4/5] fix(opencode-plugin): close sandbox acquisition and concurrent-delete races Third review round on the sync lifecycle: - Re-check the deletion tombstone at every registration point that follows an await in getSandbox: a deletion completing while a sandbox was being refreshed, reconnected, or created could otherwise persist a mapping (or a brand-new sandbox) for a session that no longer exists. A creation that loses the race discards its unregistered sandbox before throwing. - Deduplicate concurrent deleteSandbox calls through a shared per-session promise: a second teardown racing the first would observe the already- deleted sandbox, throw, and wrongly clear the tombstone, reopening the resurrection window. Typecheck clean; sync-path behavior unchanged (live E2E from the previous rounds still applies). Signed-off-by: Mislav Ivanda --- .../plugin/daytona/core/session-manager.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index 6979fcf..d069a20 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -25,6 +25,7 @@ export class DaytonaSessionManager { // so without this tombstone a sync queued behind a deletion would resurrect a fresh // sandbox for a session that no longer exists (invisible, billed, never cleaned up). private readonly deletingSessions = new Set() + private readonly deletionPromises = new Map>() private currentProjectId?: string public readonly repoPath: string @@ -113,6 +114,7 @@ export class DaytonaSessionManager { logger.info(`Starting sandbox ${existing.id} (current state: ${existing.state})`) await existing.start() } + this.ensureNotDeleted(sessionId) this.dataStorage.updateSession(projectId, worktree, sessionId, existing.id) return existing } @@ -129,6 +131,7 @@ export class DaytonaSessionManager { logger.info(`Starting sandbox begin sandboxId=${sandbox.id}`) await sandbox.start() logger.info(`Starting sandbox done sandboxId=${sandbox.id} in ${Date.now() - reconnectStart}ms`) + this.ensureNotDeleted(sessionId) this.sessionSandboxes.set(sessionId, sandbox) // Preserve branch number if it exists for this sandbox let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id) @@ -194,6 +197,13 @@ export class DaytonaSessionManager { }, 15_000) const sandbox = await daytona.create().finally(() => clearTimeout(waitingLog)) logger.info(`Daytona create done sessionId=${sessionId} sandboxId=${sandbox.id} in ${Date.now() - createStart}ms`) + if (this.deletingSessions.has(sessionId)) { + // The session was deleted while creation was in flight. The fresh sandbox is not + // registered anywhere, so nothing else will ever clean it up - discard it here. + logger.warn(`Session ${sessionId} was deleted during sandbox creation; discarding sandbox ${sandbox.id}`) + await sandbox.delete() + throw new Error(`Session ${sessionId} is deleted; the newly created sandbox was discarded.`) + } this.sessionSandboxes.set(sessionId, sandbox) // Get or assign branch number for this sandbox @@ -243,16 +253,27 @@ export class DaytonaSessionManager { * Delete the sandbox associated with the given session ID */ async deleteSandbox(sessionId: string, projectId: string): Promise { + // Concurrent deletes share one promise: a second teardown racing the first would + // observe the already-deleted sandbox, throw, and wrongly clear the tombstone. + const inFlight = this.deletionPromises.get(sessionId) + if (inFlight) return inFlight + // Tombstone first, removed again on failure: while set, no code path may create or // reconnect a sandbox for this session. Kept after success on purpose - the session // is gone, and any late event for it must no-op instead of resurrecting a sandbox. this.deletingSessions.add(sessionId) - try { - return await this.deleteSandboxInner(sessionId, projectId) - } catch (err) { - this.deletingSessions.delete(sessionId) - throw err - } + const run = (async () => { + try { + return await this.deleteSandboxInner(sessionId, projectId) + } catch (err) { + this.deletingSessions.delete(sessionId) + throw err + } finally { + this.deletionPromises.delete(sessionId) + } + })() + this.deletionPromises.set(sessionId, run) + return run } private async deleteSandboxInner(sessionId: string, projectId: string): Promise { @@ -364,4 +385,15 @@ export class DaytonaSessionManager { isSessionDeleting(sessionId: string): boolean { return this.deletingSessions.has(sessionId) } + + /** + * Guard for registration points that follow an await: deletion may have started (and + * finished) while a sandbox was being refreshed or reconnected, and persisting the + * mapping afterwards would resurrect state for a session that no longer exists. + */ + private ensureNotDeleted(sessionId: string): void { + if (this.deletingSessions.has(sessionId)) { + throw new Error(`Session ${sessionId} was deleted while its sandbox was being prepared.`) + } + } } From 9760a060ed797e22177e0525d1f28c16ade0b7e4 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 14 Aug 2026 15:55:50 +0000 Subject: [PATCH 5/5] fix(opencode-plugin): fail cleanly when deletion races sandbox initialization - Re-check the tombstone before returning a freshly created sandbox: a deletion completing during initializeAndSync owns and removes the registered sandbox, and returning it would hand callers a destroyed sandbox that fails confusingly on first use. - If discarding a creation that lost the deletion race itself fails, surface the sandbox id in the log and error so the orphan can be cleaned up manually instead of vanishing untracked. Signed-off-by: Mislav Ivanda --- .../plugin/daytona/core/session-manager.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index d069a20..c236ea1 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -201,7 +201,14 @@ export class DaytonaSessionManager { // The session was deleted while creation was in flight. The fresh sandbox is not // registered anywhere, so nothing else will ever clean it up - discard it here. logger.warn(`Session ${sessionId} was deleted during sandbox creation; discarding sandbox ${sandbox.id}`) - await sandbox.delete() + try { + await sandbox.delete() + } catch (err) { + logger.error(`Failed to discard sandbox ${sandbox.id} for deleted session ${sessionId}: ${err}`) + throw new Error( + `Session ${sessionId} is deleted and discarding newly created sandbox ${sandbox.id} failed; if it still exists, delete it from the Daytona dashboard.`, + ) + } throw new Error(`Session ${sessionId} is deleted; the newly created sandbox was discarded.`) } this.sessionSandboxes.set(sessionId, sandbox) @@ -241,6 +248,10 @@ export class DaytonaSessionManager { variant: 'error', }) } + // Deletion may have raced the initialization awaits above; the mapping was already + // registered, so the delete flow owns (and removes) the sandbox itself - returning + // it would hand callers a destroyed sandbox that fails confusingly on first use. + this.ensureNotDeleted(sessionId) toast.show({ title: 'Sandbox created', message: `Created new sandbox for session.`,