222 lines
10 KiB
Markdown
222 lines
10 KiB
Markdown
# 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" && (
|
|
<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` |
|
|
| `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.
|