# Gadget Code Abort Controller

The user can cancel/abort a work order in progress at any time. Abort is a
user-initiated action that stops the current AI API request mid-stream, signals
an `AbortError` through the agent loop, and marks the turn as `Aborted` (not
`Error`).

Abort is separate from completion. `workOrderComplete` is a normal end
condition — the agent finished its work. Abort is something the user does
_before_ completion and invalid after it. When a work order completes naturally
or is aborted, the abort signal mechanism is cleaned up end-to-end.

---

## Architecture / Message Flow

```
[Frontend]  --abortWorkOrder(cb)-->  [CodeSession]  --abortWorkOrder(cb)-->  [Drone]
                                                                                  |
                                                                           AgentService
                                                                          .abortCurrentWorkOrder()
                                                                                  |
                                                                           abortController.abort()
                                                                                  |
                                                                           AiService.abort()
                                                                           (calls AI provider's
                                                                            abort() which for
                                                                            Ollama terminates the
                                                                            HTTP request immediately)
                                                                                  |
                                                                           cb(true, "Abort signaled")
                                                                                  |
[Frontend] <------- callback received, show "Aborting..." -----------------------+
                                                                                  |
                                                                           (AbortError thrown
                                                                            in AI provider)
                                                                                  |
                                                                           AgentService catches
                                                                           AbortError, emits
                                                                           workOrderComplete(turnId, true, "aborted")
                                                                                  |
[DroneSession] --workOrderComplete(turnId, true, "aborted")--> [CodeSession]
        |                                                            |
   turn.status =                                           turn.status = "aborted"
   ChatTurnStatus.Aborted                                   displayed subtly
```

**Key design decision**: `workOrderComplete` is used with `success=true` and
`message="aborted"` to signal a normal end-by-abort. This is NOT an error
condition — the agent loop catches `AbortError` internally and emits completion
without re-throwing. The abort confirmation callback (`abortWorkOrder`'s cb)
and the turn-ending status update (`workOrderComplete`) are separate concerns.

---

## Implementation

### 1. Message Types (`@gadget/api`)

**`packages/api/src/messages/ide.ts`** — Two new types:

```ts
type AbortWorkOrderCallback = (success: boolean, message?: string) => void;
type AbortWorkOrderMessage = (cb: AbortWorkOrderCallback) => void;
```

**`packages/api/src/messages/socket.ts`** — Registered in both interfaces:

- `ClientToServerEvents.abortWorkOrder: AbortWorkOrderMessage` — IDE sends to
  Web
- `ServerToClientEvents.abortWorkOrder: AbortWorkOrderMessage` — Web forwards
  to Drone

No `SocketEvents` changes needed — the drone listens on its own socket.

### 2. AI Provider AbortSignal (`@gadget/ai`)

**`packages/ai/src/api.ts`** — Added `signal?: AbortSignal` to:

- `IAiChatOptions`
- `IAiGenerateOptions`

Also added a default (no-op) `abort()` method on the `AiApi` abstract class.
Provider-specific implementations override this to forcefully terminate
in-progress HTTP requests.

**`packages/ai/src/ollama.ts`** — In both `generate()` and `chat()`:

1. Pre-request check: `if (options.signal?.aborted) throw ...`
2. Signal passed to SDK: `...(options.signal ? { signal: options.signal } : {})`
3. Per-chunk check at top of `for await` loop:
   `if (options.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError")`

Additionally, `OllamaAiApi` overrides `abort()` to call `this.client.abort()`,
which immediately terminates the underlying HTTP request to the Ollama server.

**`packages/ai/src/openai.ts`** — Same pattern in:

- `generate()` — pre-check, pass `signal` to SDK, per-chunk in `for await`
- `readStreamingChatCompletion()` — pre-check via `signal` param, pass to SDK,
  per-chunk in `for await`
- `readNonStreamingChatCompletion()` — pass `signal` param to SDK

The `DOMException` with name `"AbortError"` is the standard web API abort error
and is detected downstream via `error.name === "AbortError"`.

### 3. Drone Abort Handling (`gadget-drone`)

**`gadget-drone/src/services/agent.ts`**:

- Property: `private abortController: AbortController | null = null`
- `process()` creates `new AbortController()` at method entry, stores on `this`
- Threads `this.abortController.signal` into `IAiChatOptions.signal` for both
  the main agent loop and the subagent spawner
- Catch block detects `AbortError`:
  ```ts
  if (cause instanceof Error && cause.name === "AbortError") {
    socket.emit("workOrderComplete", turn._id, true, "aborted");
    return; // do NOT re-throw — abort is a normal end condition
  }
  ```
- `finally` block sets `this.abortController = null`
- Public method `abortCurrentWorkOrder(): boolean` — calls
  `this.abortController?.abort()` then `AiService.abort()`, returns `true` if
  there was a controller. The `AiService.abort()` call provides provider-specific
  forceful abort (e.g., `OllamaAiApi.abort()` → `this.client.abort()`).

**`gadget-drone/src/services/ai.ts`**:

- Property: `private activeApi: AiApi | null = null` — tracks the most recently
  created `AiApi` instance
- `getApi()` stores each created instance on `this.activeApi`
- `chat()` and `generate()` clear `this.activeApi = null` in `finally` blocks
- Public method `abort(): void` — calls `this.activeApi?.abort()`, then clears
  the reference

**`gadget-drone/src/gadget-drone.ts`**:

- Registers `this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this))`
- Handler calls `AgentService.abortCurrentWorkOrder()`, then immediately calls
  `cb(aborted, aborted ? "Abort signaled" : "No active work order to abort")`
- The callback confirms before the AbortError propagates — the IDE gets
  immediate confirmation

### 4. Web Backend Routing (`gadget-code`)

**`gadget-code/src/lib/code-session.ts`**:

- Registers `this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this))`
- `onAbortWorkOrder(cb)` — looks up the `DroneSession` for `this.selectedDrone`,
  forwards with `droneSession.socket.emit("abortWorkOrder", cb)`

**`gadget-code/src/lib/drone-session.ts`**:

- `onWorkOrderComplete` — detects `success === true && message === "aborted"`,
  sets `turn.status = ChatTurnStatus.Aborted`,
  `turn.errorMessage = "The turn was aborted by you."`
- All other paths remain unchanged (Finished / Error)

### 5. Frontend (`gadget-code/frontend`)

**`frontend/src/lib/api.ts`** — Added `"aborted"` to `ChatTurn.status`:

```ts
status: "processing" | "finished" | "aborted" | "error";
```

**`frontend/src/lib/socket.ts`**:

- Added `abortWorkOrder` to `ClientToServerEvents`
- Added `abortWorkOrder(cb?)` method on `SocketClient`

**`frontend/src/pages/ChatSessionView.tsx`**:

- New state: `isAborting` boolean
- New refs: `escTimerRef` (3s double-Esc timeout), `escFlagRef` (first Esc
  tracker)
- **Cancel button**: When `isProcessing`, the submit button is replaced with a
  Cancel button (`type="button"`, red styling). While aborting, shows
  disabled "Aborting..."
- **Double-Esc**: Global `window` keydown listener, registered only while
  `isProcessing` is true. First Esc shows toast "Press Esc again to abort" with
  3s timer. Second Esc within 3s calls `handleCancel()`. Timer expiry clears
  flag and toast. Handler is cleaned up when `isProcessing` becomes false or
  component unmounts.
- **`handleCancel`**: Calls `socketClient.abortWorkOrder(cb)`. On success,
  shows "Aborting Agentic Workflow Loop..." toast. On failure, shows error
  toast and resets `isAborting`.
- **`handleWorkOrderComplete`**: Detects `message === "aborted"`, sets status
  to `"aborted"` with `errorMessage: "The turn was aborted by you."`. Cleans up
  abort state (timer, flags, aborting state).

**`frontend/src/components/ChatTurn.tsx`**:

- Status color: `"aborted"` → `text-yellow-500` (NOT red)
- Aborted display: Subtle info box (not the bright red error box):
  ```tsx
  {
    turn.status === "aborted" && (
      <div className="bg-bg-tertiary border border-border-default rounded p-3">
        <div className="text-sm text-text-secondary">
          The turn was aborted by you.
        </div>
      </div>
    );
  }
  ```
- The existing big red error block only shows for `status === "error"`, not
  `status === "aborted"`

---

## Files Changed (14 files)

| File                                                 | Change                                                                                     |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `.gitignore`                                         | Add root-level log file patterns                                                           |
| `packages/api/src/messages/ide.ts`                   | New `AbortWorkOrderCallback`, `AbortWorkOrderMessage`                                      |
| `packages/api/src/messages/socket.ts`                | Register `abortWorkOrder` in both event interfaces                                         |
| `packages/ai/src/api.ts`                             | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions`; `abort()` on `AiApi`     |
| `packages/ai/src/ollama.ts`                          | Pre-request and per-chunk abort checks, signal pass-through; `abort()` override            |
| `packages/ai/src/openai.ts`                          | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` |
| `gadget-drone/src/services/agent.ts`                 | `AbortController` property, abort detection, `abortCurrentWorkOrder()` calls `AiService.abort()` |
| `gadget-drone/src/services/ai.ts`                    | Track active `AiApi` instance, `abort()` method, cleanup in `finally` blocks               |
| `gadget-drone/src/gadget-drone.ts`                   | `onAbortWorkOrder` socket handler                                                          |
| `gadget-code/src/lib/code-session.ts`                | Forward `abortWorkOrder` to drone                                                          |
| `gadget-code/src/lib/drone-session.ts`               | Detect `"aborted"` in `workOrderComplete`                                                  |
| `gadget-code/frontend/src/lib/api.ts`                | `"aborted"` in `ChatTurn.status`                                                           |
| `gadget-code/frontend/src/lib/socket.ts`             | `abortWorkOrder` method on `SocketClient`                                                  |
| `gadget-code/frontend/src/pages/ChatSessionView.tsx` | Cancel button, double-Esc, abort flow                                                      |
| `gadget-code/frontend/src/components/ChatTurn.tsx`   | Subtle aborted display, yellow status                                                      |

---

## Developer Notes

- The `workOrderComplete` event with `message="aborted"` is the canonical way
  the system signals an aborted end. Do not add a separate abort-completion
  event or change the `workOrderComplete` signature.
- The `AbortController` is created per-process() call and cleaned up in
  `finally`. It is never shared across work orders.
- The abort callback returns immediately after the controller is signaled and
  `AiService.abort()` is called. The AbortError propagates asynchronously
  through the SDK stream and is caught separately in the agent loop.
- `AiService.abort()` provides provider-specific forceful abort (e.g., Ollama's
  `client.abort()` terminates the HTTP request immediately). This is called
  _in addition to_ the `AbortController.abort()` signal so that both the
  AbortSignal chain and the provider-specific abort mechanism are triggered.
- The active `AiApi` instance is tracked on `AiService.activeApi` and is set to
  `null` in the `finally` block of each `chat()`/`generate()` call, so the
  reference is never stale.
- Subagent loops also receive the abort signal (via `this.abortController?.signal`)
  and abort naturally — no special subagent abort handling needed.
- The Esc handler is a global `window` keydown listener. It is registered ONLY
  while `isProcessing` is true and is fully cleaned up on dependency change or
  unmount.
