gadget/docs/abort-controller.md

10 KiB

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:

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

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):
    {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.