Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/tidy-planes-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@truefoundry/trueforge-core": patch
"@truefoundry/trueforge": patch
---

Abort sandbox exec work when a turn cancellation signal aborts.
3 changes: 2 additions & 1 deletion packages/trueforge-core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export type { CodeModeErrorSource, CodeModeReply, CodeModeRequest } from './sand
export { DaytonaSandboxProvider } from './sandbox/provider/DaytonaProvider';
export type { DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider';
export { absolutizeRelativeExecEnv } from './sandbox/provider/execEnv';
export { ensureExecSuccess, shellEscape } from './sandbox/provider/Provider';
export { SANDBOX_EXEC_ABORTED, ensureExecSuccess, shellEscape } from './sandbox/provider/Provider';
export type {
ExecErrorResult,
ExecResult,
Expand All @@ -172,6 +172,7 @@ export {
export { SANDBOX_IMAGE_URI } from './sandbox/sandboxImage';
export { existingSandboxIdForProvider, formatSandboxId, parseSandboxId, rawSandboxId } from './sandbox/sandboxRef';
export type { SandboxRefParts } from './sandbox/sandboxRef';
export { onSignalAbort } from './util/abort';

// Skills: the ISkillMounter seam lets hosts plug in their own skill sources
export { InstructionBuilder } from './InstructionBuilder';
Expand Down
5 changes: 5 additions & 0 deletions packages/trueforge-core/src/core/sandbox/Sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface SandboxStoredFile {
export interface SandboxOptions {
provider: SandboxProvider;
existingSandboxId?: string | undefined;
signal?: AbortSignal | undefined;
skillMounter?: ISkillMounter | undefined;
fileDownloadEnabled?: boolean | undefined;
/** Pre-resolved credential-store file content (null = clear / no git auth). */
Expand Down Expand Up @@ -198,6 +199,7 @@ export class Sandbox extends LocalToolMCP {

private readonly provider: SandboxProvider;
private readonly existingSandboxId?: string | undefined;
private readonly signal?: AbortSignal | undefined;
private existingSandboxInfo: SandboxInfo | undefined;
// Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes.
private sandboxCreationPromise?: Promise<SandboxInfo> | undefined;
Expand Down Expand Up @@ -228,6 +230,7 @@ export class Sandbox extends LocalToolMCP {
super({ tracing: options.tracing });
this.provider = options.provider;
this.existingSandboxId = options.existingSandboxId;
this.signal = options.signal;
this.skillMounter = options.skillMounter;
this.fileDownloadEnabled = options.fileDownloadEnabled ?? false;
const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs;
Expand Down Expand Up @@ -557,6 +560,7 @@ export class Sandbox extends LocalToolMCP {
command: input.command,
cwd: input.cwd,
env,
signal: this.signal,
});

return {
Expand Down Expand Up @@ -592,6 +596,7 @@ export class Sandbox extends LocalToolMCP {
command: input.command,
cwd: input.cwd,
env: retryEnv,
signal: this.signal,
});
return {
result: {
Expand Down
132 changes: 95 additions & 37 deletions packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { suppressTracing } from '@opentelemetry/core';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path/posix';
import type { Logger } from 'winston';
import { isSignalAborted, onSignalAbort } from '../../util/abort';
import { extractErrorLogFields } from '../../util/errorLogFields';
import {
SandboxFileNotFoundError,
Expand All @@ -16,7 +17,14 @@ import {
import type { CodeModeTransport } from '../codeMode/CodeModeTransport';
import { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';
import { DEFAULT_PREVIEW_URL_EXPIRY_SECONDS, DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';
import type { ExecResult, SandboxBuild, SandboxExecParams, SandboxFileInfo, SandboxProvider } from './Provider';
import {
SANDBOX_EXEC_ABORTED,
type ExecResult,
type SandboxBuild,
type SandboxExecParams,
type SandboxFileInfo,
type SandboxProvider,
} from './Provider';

const SANDBOX_NOT_FOUND_STATUS = 404;
/** Another replica already registered this build name; its create is the one that counts. */
Expand Down Expand Up @@ -193,18 +201,22 @@ export class DaytonaSandboxProvider implements SandboxProvider {
return recovery;
}

private async executeWithSandboxRecovery<T>(sandboxId: string, operation: () => Promise<T>): Promise<T> {
private async executeWithSandboxRecovery<T>(params: {
sandboxId: string;
signal: AbortSignal | undefined;
operation: () => Promise<T>;
}): Promise<T> {
try {
return await operation();
return await params.operation();
} catch (originalError) {
// TODO: Narrow to a specific Daytona error code once @daytona/sdk exposes one for "sandbox not running".
if (!(originalError instanceof DaytonaError)) {
if (!(originalError instanceof DaytonaError) || isSignalAborted(params.signal)) {
throw originalError;
}

let recovered: boolean;
try {
recovered = await DaytonaSandboxProvider.recoverSandboxIfStopped(sandboxId);
recovered = await DaytonaSandboxProvider.recoverSandboxIfStopped(params.sandboxId);
} catch (recoveryError) {
this.logger.error('Sandbox recovery failed', {
...extractErrorLogFields(recoveryError),
Expand All @@ -218,7 +230,7 @@ export class DaytonaSandboxProvider implements SandboxProvider {
}

try {
return await operation();
return await params.operation();
} catch (retryError) {
this.logger.error('Sandbox operation failed after successful recovery', {
...extractErrorLogFields(retryError),
Expand Down Expand Up @@ -384,22 +396,56 @@ export class DaytonaSandboxProvider implements SandboxProvider {

async exec(params: SandboxExecParams): Promise<ExecResult> {
return context.with(suppressTracing(context.active()), async (): Promise<ExecResult> => {
if (isSignalAborted(params.signal)) {
return { success: false, error: SANDBOX_EXEC_ABORTED };
}
// Map the catch to abort only if this exec's handler called stop(); a later signal abort must not hide a Daytona error.
const execAbortRequested = { value: false };
try {
return await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox, defaultTimeoutMs } = await this.getOrCreateSandbox(params.sandboxId);
const response = await sandbox.process.executeCommand(
params.command,
params.cwd,
params.env ?? {},
params.timeoutSeconds ?? defaultTimeoutMs / 1000,
);
return {
success: true,
response: { exitCode: response.exitCode, result: response.result },
};
return await this.executeWithSandboxRecovery({
sandboxId: params.sandboxId,
signal: params.signal,
operation: async () => {
const { sandbox, defaultTimeoutMs } = await this.getOrCreateSandbox(params.sandboxId);
const timeoutSeconds = params.timeoutSeconds ?? defaultTimeoutMs / 1000;
if (isSignalAborted(params.signal)) {
return { success: false, error: SANDBOX_EXEC_ABORTED };
}
const cleanupAbort = onSignalAbort(params.signal, () => {
execAbortRequested.value = true;
void sandbox.stop(timeoutSeconds, true).catch((error: unknown) => {
this.logger.error('Failed to stop Daytona sandbox after exec abort', {
...extractErrorLogFields(error),
sandboxId: params.sandboxId,
});
});
});
try {
const response = await sandbox.process.executeCommand(
params.command,
params.cwd,
params.env ?? {},
timeoutSeconds,
);
if (execAbortRequested.value) {
// Drop only the stale SDK object. The remote sandbox and filesystem stay under the same id.
DaytonaSandboxProvider.cachedSandboxes.delete(params.sandboxId);
Comment thread
thesujai marked this conversation as resolved.
return { success: false, error: SANDBOX_EXEC_ABORTED };
}
return {
success: true,
response: { exitCode: response.exitCode, result: response.result },
};
} finally {
cleanupAbort();
}
},
});
} catch (e: unknown) {
DaytonaSandboxProvider.cachedSandboxes.delete(params.sandboxId);
if (execAbortRequested.value) {
return { success: false, error: SANDBOX_EXEC_ABORTED };
}
if (e instanceof SandboxNotAvailableError) {
throw e;
}
Expand All @@ -418,18 +464,22 @@ export class DaytonaSandboxProvider implements SandboxProvider {
async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {
return context.with(suppressTracing(context.active()), async () => {
try {
return await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);

const info = await this.getFileInfo(sandbox, params.path);
if (info.isDir) {
throw new SandboxPathIsDirectoryError(params.path);
}
if (info.size > this.fileMaxBytesForDownload) {
throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);
}

return await sandbox.fs.downloadFile(params.path);
return await this.executeWithSandboxRecovery({
sandboxId: params.sandboxId,
signal: undefined,
operation: async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);

const info = await this.getFileInfo(sandbox, params.path);
if (info.isDir) {
throw new SandboxPathIsDirectoryError(params.path);
}
if (info.size > this.fileMaxBytesForDownload) {
throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);
}

return await sandbox.fs.downloadFile(params.path);
},
});
} catch (e: unknown) {
if (e instanceof SandboxPathIsDirectoryError || e instanceof SandboxFileTooLargeError) {
Expand All @@ -447,9 +497,13 @@ export class DaytonaSandboxProvider implements SandboxProvider {
async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {
return context.with(suppressTracing(context.active()), async () => {
try {
await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);
await sandbox.fs.uploadFile(params.content, params.remotePath);
await this.executeWithSandboxRecovery({
sandboxId: params.sandboxId,
signal: undefined,
operation: async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);
await sandbox.fs.uploadFile(params.content, params.remotePath);
},
});
} catch (e: unknown) {
DaytonaSandboxProvider.cachedSandboxes.delete(params.sandboxId);
Expand All @@ -462,10 +516,14 @@ export class DaytonaSandboxProvider implements SandboxProvider {
private async getPreviewUrl(params: { sandboxId: string; port: number; expiresInSeconds: number }): Promise<string> {
return context.with(suppressTracing(context.active()), async () => {
try {
return await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);
const signed = await sandbox.getSignedPreviewUrl(params.port, params.expiresInSeconds);
return signed.url;
return await this.executeWithSandboxRecovery({
sandboxId: params.sandboxId,
signal: undefined,
operation: async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);
const signed = await sandbox.getSignedPreviewUrl(params.port, params.expiresInSeconds);
return signed.url;
},
});
} catch (e: unknown) {
DaytonaSandboxProvider.cachedSandboxes.delete(params.sandboxId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface ExecErrorResult {

export type ExecResult = ExecSuccessResult | ExecErrorResult;

export const SANDBOX_EXEC_ABORTED = 'Sandbox exec aborted';

/**
* Wraps a value in single quotes for safe use in a shell command. Inner single quotes are
* escaped via the standard shell idiom: ' -> '\''. Use for any user-controlled value that is
Expand Down Expand Up @@ -44,6 +46,7 @@ export interface SandboxExecParams {
command: string;
cwd?: string | undefined;
env?: Record<string, string> | undefined;
signal?: AbortSignal | undefined;
/** Overrides the provider's default exec timeout (e.g. for long skill downloads). */
timeoutSeconds?: number | undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import dedent from 'dedent';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path/posix';
import type { Logger } from 'winston';
import { isSignalAborted, onSignalAbort } from '../../util/abort';
import { extractErrorLogFields } from '../../util/errorLogFields';
import type { CodeModeTransport } from '../codeMode/CodeModeTransport';
import { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';
Expand All @@ -17,6 +18,7 @@ import {
import { absolutizeRelativeExecEnv } from './execEnv';
import {
ensureExecSuccess,
SANDBOX_EXEC_ABORTED,
shellEscape,
type ExecResult,
type SandboxBuild,
Expand Down Expand Up @@ -152,9 +154,15 @@ export class TFYSandboxProvider implements SandboxProvider {

const controller = new AbortController();
const clientTimeoutMs = (timeoutSeconds + CLIENT_TIMEOUT_BUFFER_SECONDS) * 1000;
const abortState: { source: 'timeout' | 'turn' | undefined } = { source: undefined };
const timer = setTimeout(() => {
abortState.source = 'timeout';
controller.abort();
}, clientTimeoutMs);
const cleanupAbort = onSignalAbort(params.signal, () => {
abortState.source = 'turn';
controller.abort();
});

try {
const response = await fetch(`${this.serverUrl}/exec`, {
Expand All @@ -174,6 +182,9 @@ export class TFYSandboxProvider implements SandboxProvider {
return result;
} catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') {
if (abortState.source === 'turn' && isSignalAborted(params.signal)) {
return { success: false, error: SANDBOX_EXEC_ABORTED };
}
this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, extractErrorLogFields(e));
return { success: false, error: `Sandbox exec timed out after ${String(timeoutSeconds)}s` };
}
Expand All @@ -182,6 +193,7 @@ export class TFYSandboxProvider implements SandboxProvider {
return { success: false, error: message };
} finally {
clearTimeout(timer);
cleanupAbort();
}
});
}
Expand Down
13 changes: 10 additions & 3 deletions packages/trueforge-core/src/core/util/abort.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
export function onSignalAbort(signal: AbortSignal | undefined, callback: () => void): void {
export function isSignalAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true;
}

export function onSignalAbort(signal: AbortSignal | undefined, callback: () => void): () => void {
if (!signal) {
return;
return () => undefined;
}
if (signal.aborted) {
callback();
return;
return () => undefined;
}
signal.addEventListener('abort', callback, { once: true });
return () => {
signal.removeEventListener('abort', callback);
};
}
Loading
Loading