gadget/docs/abort-controller.md

13 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()
                                                                                  |
                                                                           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:

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

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; 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.