docs: update abort controller documentation with full technical specification
This commit is contained in:
parent
4780b79148
commit
1bb2a1d392
@ -1,28 +1,221 @@
|
||||
# Gadget Code Abort Controller
|
||||
|
||||
The User needs to be able to cancel/abort a work order in progress.
|
||||
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`).
|
||||
|
||||
## gadget-code:frontend
|
||||
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.
|
||||
|
||||
The view already knows if a work order/prompt is running. When a prompt is running, the prompt Send button becomes a Cancel button. When pressed, we send the `abortWorkOrder` message, wait for the callback, and respond accordingly.
|
||||
---
|
||||
|
||||
The User can also trigger an Abort by pressing Esc twice. On the first press, display a message: Press Esc again to abort. That message times out after 3 seconds. The flow for this:
|
||||
## Architecture / Message Flow
|
||||
|
||||
1. User submits prompt, turn created, processing begins.
|
||||
2. At any time while processing, the User presses Esc.
|
||||
1. Display "Press Esc again to abort" and start 3-second timer
|
||||
2. If User presses Esc again, abort the work order and close this message.
|
||||
3. When 3 seconds pass, close this message
|
||||
3. When a work order is aborted or finishes naturally, clean up the abort controller in both gadget-code:frontend and gadget-drone.
|
||||
```
|
||||
[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
|
||||
```
|
||||
|
||||
## gadget-code:backend
|
||||
**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.
|
||||
|
||||
`gadget-code:backend` plays no active role in abort procedures other than routing the messages between `gadget-code:frontend` and `gadget-drone`. These aborts don't alter session state in the backend. It is facilitating communication between the components and, in this context, that is it's only job.
|
||||
---
|
||||
|
||||
## gadget-drone
|
||||
## Implementation
|
||||
|
||||
The drone should use a signalable abort. If the IDE sends an `abortWorkOrder` event, signal the AI API abort. The `abortWorkOrder` message should use a callback to indicate success/fail.
|
||||
### 1. Message Types (`@gadget/api`)
|
||||
|
||||
When the drone receives an `abortWorkOrder` message, it signals the abort controller for the running AI API call. The abort controller aborts the request to the AI provider API, and lets that become an "Aborted" error. The system's error processing will deliver that to the IDE. The IDE looks for the Abort error (let's talk about how we're going to identify and define it!), marks the Turn's status as Aborted.
|
||||
**`packages/api/src/messages/ide.ts`** — Two new types:
|
||||
|
||||
I have added the Aborted status to ChatTurnStatus to be used as the turn's status if aborted. The UI should _not_ display this as an error with giant bright-red styling like some kind of emergency alert. The Abort message should just confirm and display to the User: "The turn was aborted by you."
|
||||
```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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user