11 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 WebServerToClientEvents.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:
IAiChatOptionsIAiGenerateOptions
packages/ai/src/ollama.ts — In both generate() and chat():
- Pre-request check:
if (options.signal?.aborted) throw ... - Signal passed to SDK:
...(options.signal ? { signal: options.signal } : {}) - Per-chunk check at top of
for awaitloop:if (options.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError")
packages/ai/src/openai.ts — Same pattern in:
generate()— pre-check, passsignalto SDK, per-chunk infor awaitreadStreamingChatCompletion()— pre-check viasignalparam, pass to SDK, per-chunk infor awaitreadNonStreamingChatCompletion()— passsignalparam 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()createsnew AbortController()at method entry, stores onthis- Threads
this.abortController.signalintoIAiChatOptions.signalfor 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 } finallyblock setsthis.abortController = null- Public method
abortCurrentWorkOrder(): boolean— callsthis.abortController?.abort(), returnstrueif 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 callscb(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 theDroneSessionforthis.selectedDrone, forwards withdroneSession.socket.emit("abortWorkOrder", cb)
gadget-code/src/lib/drone-session.ts:
onWorkOrderComplete— detectssuccess === true && message === "aborted", setsturn.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
abortWorkOrdertoClientToServerEvents - Added
abortWorkOrder(cb?)method onSocketClient
frontend/src/pages/ChatSessionView.tsx:
- New state:
isAbortingboolean - 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
windowkeydown listener, registered only whileisProcessingis true. First Esc shows toast "Press Esc again to abort" with 3s timer. Second Esc within 3s callshandleCancel(). Timer expiry clears flag and toast. Handler is cleaned up whenisProcessingbecomes false or component unmounts. handleCancel: CallssocketClient.abortWorkOrder(cb). On success, shows "Aborting Agentic Workflow Loop..." toast. On failure, shows error toast and resetsisAborting.handleWorkOrderComplete: Detectsmessage === "aborted", sets status to"aborted"witherrorMessage: "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", notstatus === "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
workOrderCompleteevent withmessage="aborted"is the canonical way the system signals an aborted end. Do not add a separate abort-completion event or change theworkOrderCompletesignature. - The
AbortControlleris created per-process() call and cleaned up infinally. 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
windowkeydown listener. It is registered ONLY whileisProcessingis true and is fully cleaned up on dependency change or unmount.