# How AbortSignal Cancels Runs and Preserves Worktrees in Sandcastle

> Learn how Sandcastle leverages AbortSignal to safely cancel agent sessions. Discover how it preserves worktrees with uncommitted changes, ensuring your work isn't lost.

- Repository: [Matt Pocock/sandcastle](https://github.com/mattpocock/sandcastle)
- Tags: deep-dive
- Published: 2026-05-24

---

**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`](https://github.com/mattpocock/sandcastle/blob/main/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`](https://github.com/mattpocock/sandcastle/blob/main/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.

```typescript
// 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`](https://github.com/mattpocock/sandcastle/blob/main/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.

```typescript
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:

1. **Caller creates a worktree** using `createWorktree()`.
2. **Caller invokes** `run()` or `interactive()` and passes an `AbortSignal` (typically from an `AbortController`).
3. The function immediately checks `signal?.throwIfAborted()`.
4. The sandbox execution is wrapped by `raceAbortSignal`.
5. If the signal aborts before the effect finishes, `raceAbortSignal` fires the deferred, the effect dies with the original abort reason, and the sandbox subprocess is killed.
6. The surrounding error handling re-throws the abort reason after calling `signal?.throwIfAborted()` again to surface the exact user-provided error.
7. When the `Worktree` handle is later `close()`-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:

```typescript
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**: `raceAbortSignal` in [`src/raceAbortSignal.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/raceAbortSignal.ts) wraps sandbox execution and kills the subprocess when the signal fires.
- **Worktree preservation**: The `close()` method checks `WorktreeManager.hasUncommittedChanges` and returns `preservedWorktreePath` instead of deleting dirty worktrees.
- **Cooperative cancellation**: The standard Web API `AbortSignal` integrates 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`](https://github.com/mattpocock/sandcastle/blob/main/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.