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..c236ea1 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' @@ -21,6 +21,11 @@ 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 readonly deletionPromises = new Map>() private currentProjectId?: string public readonly repoPath: string @@ -83,6 +88,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({ @@ -106,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 } @@ -122,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) @@ -187,6 +197,20 @@ 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}`) + 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) // Get or assign branch number for this sandbox @@ -224,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.`, @@ -236,6 +264,32 @@ 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) + 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 { + await SessionGitManager.waitForPendingSync(sessionId) + let sandbox = this.sessionSandboxes.get(sessionId) // Read-only lookup so deleting never migrates sessions or rewrites project metadata. @@ -270,8 +324,15 @@ export class DaytonaSessionManager { // Delete the sandbox if we have a fully initialized one let deleted = false if (this.isFullyInitialized(sandbox)) { - 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.`) @@ -290,4 +351,60 @@ 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, 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(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 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 { + 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) + } + + /** + * 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.`) + } + } } 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..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,6 +61,19 @@ export class DaytonaSandboxGitManager { return branch.trim() } + /** + * 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 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 { // 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 8be5ea8..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 @@ -43,11 +43,75 @@ 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, 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) { + 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 { const sshAccess = await this.sandbox.createSshAccess(10) 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 @@ -103,10 +167,14 @@ export class SessionGitManager { } await this.sandboxGit.ensureRepo() - const hasChanges = await this.sandboxGit.autoCommit() + await this.sandboxGit.autoCommit() - // Only sync and notify if there were actual changes - if (!hasChanges) { + // 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 69d4ed6..110cf90 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -27,8 +27,9 @@ 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' import { customTools } from './plugins/custom-tools' import { eventHandlers } from './plugins/session-events' @@ -50,6 +51,16 @@ 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. + // Bounded so a sync stalled on an unreachable sandbox cannot wedge process exit. + dispose: async () => { + 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 ca5e865..141b46d 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,20 @@ 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 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 () => { + // 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 + 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/.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..7e4431d 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 — 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 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: