Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string>()
private readonly deletionPromises = new Map<string, Promise<boolean>>()
private currentProjectId?: string
public readonly repoPath: string

Expand Down Expand Up @@ -83,6 +88,9 @@ export class DaytonaSessionManager {
if (pluginCtx?.client?.tui) {
toast.initialize(pluginCtx.client.tui)
}
if (this.deletingSessions.has(sessionId)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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({
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -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.`,
Expand All @@ -236,6 +264,32 @@ export class DaytonaSessionManager {
* Delete the sandbox associated with the given session ID
*/
async deleteSandbox(sessionId: string, projectId: string): Promise<boolean> {
// 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)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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<boolean> {
await SessionGitManager.waitForPendingSync(sessionId)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

let sandbox = this.sessionSandboxes.get(sessionId)

// Read-only lookup so deleting never migrates sessions or rewrites project metadata.
Expand Down Expand Up @@ -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 () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
await this.syncBeforeDelete(target, stored)
logger.info(`Removing sandbox for session: ${sessionId}`)
await target.delete()
})
deleted = true
sandboxGone = true
logger.info(`Sandbox deleted successfully.`)
Expand All @@ -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.`)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<void> {
// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<void>>()

/**
* 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<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
const prev = SessionGitManager.pendingSyncs.get(sessionId) ?? Promise.resolve()
const operation = prev.then(fn)
const stored: Promise<void> = 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<void> {
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<boolean> {
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs
while (SessionGitManager.pendingSyncs.size > 0) {
const waits: Promise<unknown>[] = [Promise.all([...SessionGitManager.pendingSyncs.values()])]
if (deadline !== undefined) {
const remaining = deadline - Date.now()
if (remaining <= 0) return false
waits.push(
new Promise<void>((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<string> {
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
Expand Down Expand Up @@ -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}`)) {
Comment thread
mislavivanda marked this conversation as resolved.
return false
}

Expand Down
13 changes: 12 additions & 1 deletion packages/opencode-plugin/.opencode/plugin/daytona/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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')
}
},
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
)
}
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode-plugin/.opencode/plugin/daytona/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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),
}
}
Loading
Loading