What problem are you trying to solve?
When a user cancels a turn, the turn today waits for any running sandbox execution until that execution times out.
Proposed solution
Support abort on the exec tool only.
Do not add abort to other sandbox operations.
Give AbortSignal to Sandbox at construct time through SandboxOptions.
The factory already receives the signal.
Current behavior:
TurnSandboxFactory input includes signal: AbortSignal.
TurnResourceResolver.resolveSandbox sends signal to the factory.
- The turn sandbox factory in
turns.ts does not use the signal. It only reads spec, existingSandboxId, and tracing.
buildTurnSandbox and SandboxOptions do not take a signal.
Plan:
- Add optional
signal?: AbortSignal to SandboxOptions. Keep that signal on the Sandbox instance when present.
- Add optional
signal?: AbortSignal to SandboxExecParams. From Sandbox, send this.signal only on the exec tool path (handleExec → provider.exec). Do not send the turn signal on init, skills, or mkdir. Do not send abort through executeToolCalls.
- Change the factory and
buildTurnSandbox so that they send the factory signal into new Sandbox(...).
- Update provider implementations so that they honor
params.signal when present.
- Each provider uses the signal as follows:
- Daytona: When the signal aborts, force-stop the sandbox with
sandbox.stop(timeout, true) from local @daytona/sdk. force: true sends SIGKILL. The next turn can use the same sandbox id again. restoreExistingSandbox already calls start when the sandbox is not started. The filesystem stays. Process memory and Code Mode state do not stay.
- TFY: In
TFYSandboxProvider, abort the HTTP fetch when the turn signal aborts or when the exec timeout ends. How you combine the signals is an implementation detail.
- Local: When the signal aborts, stop the current exec process tree.
runSupervisorSession already uses killExecTree for timeout and for output overflow. killExecTree sends SIGKILL to the process group. See packages/trueforge/src/sandbox/local/core/hostRun.ts. Send params.signal into runSupervisorSession. Call the same killExecTree when the signal aborts. Do not delete the sandbox root.
When the cancel API aborts the turn signal, the exec tool can stop before the command timeout ends.
This change does not apply to freezeTurn with reason cancelled-for-next-turn.
Acceptance criteria
Alternatives considered
- Add a
stop method on Sandbox and SandboxProvider instead of abort on exec. Do not do this now. Abort on the exec tool is enough for this cancel path.
Additional context
- Callers send
AbortSignal into the agent loop and into TurnSandboxFactory. The signal is not stored on Sandbox. The signal is not sent to provider.exec.
- TFY aborts the HTTP
fetch only when the exec timeout ends in TFYSandboxProvider.postExec. Requirement: also abort when the turn signal aborts.
- Daytona
executeCommand has no abort parameter. Force stop is on the sandbox method stop in local @daytona/sdk@0.204.1.
Sandbox also calls provider.exec for init, skills, and mkdir. Those paths stay without the turn abort signal.
What problem are you trying to solve?
When a user cancels a turn, the turn today waits for any running sandbox execution until that execution times out.
Proposed solution
Support abort on the
exectool only.Do not add abort to other sandbox operations.
Give
AbortSignaltoSandboxat construct time throughSandboxOptions.The factory already receives the signal.
Current behavior:
TurnSandboxFactoryinput includessignal: AbortSignal.TurnResourceResolver.resolveSandboxsendssignalto the factory.turns.tsdoes not use the signal. It only readsspec,existingSandboxId, andtracing.buildTurnSandboxandSandboxOptionsdo not take a signal.Plan:
signal?: AbortSignaltoSandboxOptions. Keep that signal on theSandboxinstance when present.signal?: AbortSignaltoSandboxExecParams. FromSandbox, sendthis.signalonly on theexectool path (handleExec→provider.exec). Do not send the turn signal on init, skills, or mkdir. Do not send abort throughexecuteToolCalls.buildTurnSandboxso that they send the factorysignalintonew Sandbox(...).params.signalwhen present.sandbox.stop(timeout, true)from local@daytona/sdk.force: truesends SIGKILL. The next turn can use the same sandbox id again.restoreExistingSandboxalready callsstartwhen the sandbox is not started. The filesystem stays. Process memory and Code Mode state do not stay.TFYSandboxProvider, abort the HTTPfetchwhen the turnsignalaborts or when the exec timeout ends. How you combine the signals is an implementation detail.runSupervisorSessionalready useskillExecTreefor timeout and for output overflow.killExecTreesends SIGKILL to the process group. Seepackages/trueforge/src/sandbox/local/core/hostRun.ts. Sendparams.signalintorunSupervisorSession. Call the samekillExecTreewhen the signal aborts. Do not delete the sandbox root.When the cancel API aborts the turn signal, the
exectool can stop before the command timeout ends.This change does not apply to
freezeTurnwith reasoncancelled-for-next-turn.Acceptance criteria
execends the turn without a wait for the full exec timeout. This applies to Daytona and Local.executeWithSandboxRecoverymust notstartthe sandbox and run the same cancelled command again onDaytonaError.existingSandboxId. The sandbox starts. The filesystem is available. Any commands run successfully.mcp_clienttools cache{server}.tools.jsonis corrupt or truncated, the client deletes it and refetches over NATS.freezeTurnwith reasoncancelled-for-next-turndoes not change.Alternatives considered
stopmethod onSandboxandSandboxProviderinstead of abort onexec. Do not do this now. Abort on theexectool is enough for this cancel path.Additional context
AbortSignalinto the agent loop and intoTurnSandboxFactory. The signal is not stored onSandbox. The signal is not sent toprovider.exec.fetchonly when the exec timeout ends inTFYSandboxProvider.postExec. Requirement: also abort when the turnsignalaborts.executeCommandhas no abort parameter. Force stop is on the sandbox methodstopin local@daytona/sdk@0.204.1.Sandboxalso callsprovider.execfor init, skills, and mkdir. Those paths stay without the turn abort signal.