How AbortSignal Cancels Runs and Preserves Worktrees in Sandcastle
Sandcastle uses the standard Web API AbortSignal to cancel running agent sessions while preserving the temporary Git worktree if it contains uncommitted changes.
Sandcastle is a sandboxed code execution framework that spins up temporary Git worktrees for isolated agent runs. According to the mattpocock/sandcastle source code, the repository implements cooperative cancellation through the standard AbortSignal interface, ensuring that aborting a long-running task does not destroy potentially valuable work-in-progress.
The Three-Pillar Abort Architecture
The abort workflow rests on three coordinated mechanisms: early-exit validation, signal racing, and conditional worktree preservation. These layers ensure that cancellation is both immediate and safe.
Early-Exit Checks with throwIfAborted
Before Sandcastle performs any heavyweight setup—such as creating the Git worktree or starting the sandbox container—it validates the signal state. The code calls signal?.throwIfAborted() at the entry points of both interactive and batch operations.
If the signal is already aborted, the function rejects immediately without touching the host repository. This check appears in src/createWorktree.ts inside the worktreeInteractive and worktreeRun functions around lines 74-76.
Racing the Signal Against Effects
The actual sandbox execution is wrapped with a custom helper called raceAbortSignal, implemented in src/raceAbortSignal.ts. This utility creates a deferred promise that resolves when the AbortSignal fires, then races it against the original Effect-TS Effect.
If the signal fires first, the effect is killed with Effect.die(signal.reason). The abort listener is always removed afterward to prevent memory leaks.
// Inside worktreeRun or worktreeInteractive
const result = yield* raceAbortSignal(
Effect.promise(() => interactiveExecFn(...)),
opts.signal,
);
Preserving Dirty Worktrees on Abort
After an abort occurs, Sandcastle intentionally avoids automatic cleanup if the worktree contains uncommitted changes. The close() method defined in src/createWorktree.ts (lines 50-68) queries WorktreeManager.hasUncommittedChanges to inspect the repository state.
If the worktree is dirty, close() returns the path in preservedWorktreePath instead of deleting the directory. This guarantees that any work the agent performed before cancellation remains available for debugging or reuse.
const isDirty = yield* WorktreeManager.hasUncommittedChanges(worktreeInfo.path)
.pipe(Effect.catchAll(() => Effect.succeed(false)));
if (isDirty) {
return { preservedWorktreePath: worktreeInfo.path };
}
Complete Abort Flow
The end-to-end cancellation path follows these steps:
- Caller creates a worktree using
createWorktree(). - Caller invokes
run()orinteractive()and passes anAbortSignal(typically from anAbortController). - The function immediately checks
signal?.throwIfAborted(). - The sandbox execution is wrapped by
raceAbortSignal. - If the signal aborts before the effect finishes,
raceAbortSignalfires the deferred, the effect dies with the original abort reason, and the sandbox subprocess is killed. - The surrounding error handling re-throws the abort reason after calling
signal?.throwIfAborted()again to surface the exact user-provided error. - When the
Worktreehandle is laterclose()-d, Sandcastle inspects for pending changes. If any exist, the worktree is kept on disk and its path is returned.
Practical Implementation Example
The following example demonstrates setting up a cancellable run with a 10-second timeout:
import { AbortController } from "node:abort-controller";
import { createWorktree } from "./createWorktree.js";
// 1️⃣ Create a worktree (branch strategy can be 'branch' or 'merge-to-head')
const wt = await createWorktree({
branchStrategy: { type: "branch", branch: "feature/xyz" },
});
// 2️⃣ Set up an AbortController that will cancel after 10 seconds
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("User timeout")), 10_000);
// 3️⃣ Run a sandboxed agent, passing the signal
try {
const result = await wt.run({
agent: claudeCode("claude-opus-4-7"),
sandbox: docker(),
prompt: "Update the README",
signal: controller.signal, // <-- aborts the run when fired
});
console.log("Run completed:", result);
} catch (e) {
// If the abort fired, the original error is surfaced unchanged
console.error("Run aborted:", e);
}
// 4️⃣ Clean up – the worktree will be removed only if it is clean
const closeInfo = await wt.close();
if (closeInfo.preservedWorktreePath) {
console.log("Worktree preserved at:", closeInfo.preservedWorktreePath);
}
Summary
- Early validation:
signal?.throwIfAborted()prevents unnecessary setup if the signal is already aborted. - Effect racing:
raceAbortSignalinsrc/raceAbortSignal.tswraps sandbox execution and kills the subprocess when the signal fires. - Worktree preservation: The
close()method checksWorktreeManager.hasUncommittedChangesand returnspreservedWorktreePathinstead of deleting dirty worktrees. - Cooperative cancellation: The standard Web API
AbortSignalintegrates with Effect-TS to provide type-safe, leak-free abort semantics.
Frequently Asked Questions
How does Sandcastle prevent memory leaks when aborting?
The raceAbortSignal helper always removes the abort event listener after the race settles, regardless of whether the signal fired or the effect completed. This is implemented in src/raceAbortSignal.ts using a Deferred pattern that guarantees cleanup.
Can I abort a run after it has already started?
Yes. Sandcastle supports aborting both interactive sessions and batch runs at any point during execution. The raceAbortSignal mechanism will terminate the underlying agent subprocess and surface the abort reason through the Effect-TS error channel.
What happens if the worktree is clean when I abort?
If WorktreeManager.hasUncommittedChanges returns false (or errors), the close() method proceeds with normal cleanup and removes the temporary worktree directory. The preservation logic only triggers when uncommitted changes exist, protecting work-in-progress from accidental deletion.
Does Sandcastle support multiple concurrent abort signals?
Each run() or interactive() call accepts a single AbortSignal instance. If you need to compose multiple cancellation sources (such as timeouts and user cancellation), combine them using AbortSignal.any() (or polyfills) before passing the composite signal to Sandcastle.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →