# 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() | 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` **`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")` **`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()`, returns `true` if there was a controller **`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" && (
The turn was aborted by you.
); } ``` - 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` | | `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through | | `packages/ai/src/openai.ts` | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` | | `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` | | `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. The AbortError propagates asynchronously through the SDK stream and is caught separately in the agent loop. - 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.