diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index fe4d7c0..6454e1b 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -65,6 +65,7 @@ import { import { prepareChatMessageContent } from '@shared/utils/chatMessage'; import { appendRunActivityEvent, + cancelSessionRunRecord, completeSessionRunRecord, createSessionRunRecord, failSessionRunRecord, @@ -99,6 +100,7 @@ import { SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE, SidecarClient, } from '@main/sidecar/sidecarProcess'; +import { TurnCancelledError } from '@main/sidecar/turnCancelledError'; import { GitService } from '@main/git/gitService'; import { buildRunTurnToolingConfig as buildSessionToolingConfig, @@ -621,6 +623,12 @@ export class EryxAppService extends EventEmitter { this.finalizeTurn(workspace, session.id, requestId, responseMessages); await this.persistAndBroadcast(workspace); } catch (error) { + if (error instanceof TurnCancelledError) { + this.finalizeCancelledTurn(workspace, session, requestId); + await this.persistAndBroadcast(workspace); + return; + } + const failedAt = nowIso(); session.status = 'error'; session.lastError = error instanceof Error ? error.message : String(error); @@ -643,6 +651,21 @@ export class EryxAppService extends EventEmitter { } } + async cancelSessionTurn(sessionId: string): Promise { + const workspace = await this.loadWorkspace(); + const session = this.requireSession(workspace, sessionId); + if (session.status !== 'running') { + return; + } + + const runningRun = session.runs.find((run) => run.status === 'running'); + if (!runningRun) { + return; + } + + await this.sidecar.cancelTurn(runningRun.requestId); + } + async resolveSessionApproval( sessionId: string, approvalId: string, @@ -1101,6 +1124,36 @@ export class EryxAppService extends EventEmitter { } } + private finalizeCancelledTurn( + workspace: WorkspaceState, + session: SessionRecord, + requestId: string, + ): void { + for (const message of session.messages) { + if (message.pending) { + message.pending = false; + } + } + + this.rejectPendingApprovals(session, nowIso(), 'The turn was cancelled.'); + + const cancelledAt = nowIso(); + session.status = 'idle'; + session.lastError = undefined; + session.updatedAt = cancelledAt; + const cancelledRun = this.updateSessionRun(session, requestId, (run) => + cancelSessionRunRecord(run, cancelledAt)); + this.emitSessionEvent({ + sessionId: session.id, + kind: 'status', + occurredAt: cancelledAt, + status: 'idle', + }); + if (cancelledRun) { + this.emitRunUpdated(session.id, cancelledAt, cancelledRun); + } + } + private async handleApprovalRequested( workspace: WorkspaceState, sessionId: string, diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 334ceb2..e61865f 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -2,6 +2,7 @@ import { BrowserWindow, ipcMain } from 'electron'; import { ipcChannels } from '@shared/contracts/channels'; import type { + CancelSessionTurnInput, CreateSessionInput, ResolveProjectDiscoveredToolingInput, ResolveWorkspaceDiscoveredToolingInput, @@ -100,6 +101,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) => service.sendSessionMessage(input.sessionId, input.content), ); + ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) => + service.cancelSessionTurn(input.sessionId), + ); ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) => service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision), ); diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index b59f1a2..a6d4e20 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -4,6 +4,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import type { AgentActivityEvent, ApprovalRequestedEvent, + CancelTurnCommand, SidecarCommand, SidecarCapabilities, SidecarEvent, @@ -19,29 +20,54 @@ import { shouldHandleRunTurnEvent, type RunTurnPendingCommand, } from '@main/sidecar/runTurnPending'; +import { TurnCancelledError } from '@main/sidecar/turnCancelledError'; import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime'; type PendingCommand = - | { + | ({ + processId: number; kind: 'capabilities'; resolve: (capabilities: SidecarCapabilities) => void; reject: (error: Error) => void; - } - | { + }) + | ({ + processId: number; kind: 'validate-pattern'; resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void; reject: (error: Error) => void; - } - | { + }) + | ({ + processId: number; kind: 'resolve-approval'; resolve: () => void; reject: (error: Error) => void; - } - | RunTurnPendingCommand; + }) + | ({ + processId: number; + kind: 'cancel-turn'; + resolve: () => void; + reject: (error: Error) => void; + }) + | ({ + processId: number; + } & RunTurnPendingCommand); + +type ManagedSidecarProcess = { + id: number; + child: ChildProcessWithoutNullStreams; + stdoutBuffer: string; + exitExpected: boolean; + terminated: boolean; + closed: Promise; + resolveClosed: () => void; +}; + +export const SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE = + 'The .NET sidecar was stopped before the command completed.'; export class SidecarClient { - private process?: ChildProcessWithoutNullStreams; - private stdoutBuffer = ''; + private processState?: ManagedSidecarProcess; + private nextProcessId = 0; private readonly pending = new Map(); async describeCapabilities(): Promise { @@ -79,18 +105,39 @@ export class SidecarClient { }); } + async cancelTurn(targetRequestId: string): Promise { + return this.dispatch({ + type: 'cancel-turn', + requestId: `cancel-${Date.now()}`, + targetRequestId, + } satisfies CancelTurnCommand); + } + async dispose(): Promise { - if (!this.process) { + const state = this.processState; + if (!state) { return; } - this.process.kill(); - this.process = undefined; + state.exitExpected = true; + if (!state.child.killed && state.child.exitCode === null) { + state.child.kill(); + } + await state.closed; } - private async ensureProcess(): Promise { - if (this.process && !this.process.killed) { - return this.process; + private async ensureProcess(): Promise { + if ( + this.processState && + !this.processState.exitExpected && + !this.processState.terminated && + this.processState.child.exitCode === null + ) { + return this.processState; + } + + if (this.processState) { + await this.processState.closed; } const sidecar = resolveSidecarProcess({ @@ -105,12 +152,29 @@ export class SidecarClient { stdio: 'pipe', windowsHide: true, }); - this.process = childProcess; + let resolveClosed!: () => void; + const state: ManagedSidecarProcess = { + id: this.nextProcessId + 1, + child: childProcess, + stdoutBuffer: '', + exitExpected: false, + terminated: false, + closed: new Promise((resolve) => { + resolveClosed = resolve; + }), + resolveClosed, + }; + this.nextProcessId = state.id; + this.processState = state; childProcess.stdout.setEncoding('utf8'); childProcess.stdout.on('data', (chunk: string) => { - this.stdoutBuffer += chunk; - this.flushStdoutBuffer(); + if (state.terminated) { + return; + } + + state.stdoutBuffer += chunk; + this.flushStdoutBuffer(state); }); childProcess.stderr.setEncoding('utf8'); @@ -118,17 +182,14 @@ export class SidecarClient { console.error('[aryx sidecar]', chunk.trim()); }); - childProcess.on('exit', (code) => { - const error = new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`); - for (const pending of this.pending.values()) { - pending.reject(error); - } - this.pending.clear(); - this.process = undefined; - this.stdoutBuffer = ''; + childProcess.on('close', (code) => { + const error = state.exitExpected + ? new Error(SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE) + : new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`); + this.handleProcessClosed(state, error); }); - return childProcess; + return state; } private async dispatch( @@ -137,11 +198,12 @@ export class SidecarClient { onActivity?: (event: AgentActivityEvent) => void | Promise, onApproval?: (event: ApprovalRequestedEvent) => void | Promise, ): Promise { - const process = await this.ensureProcess(); + const state = await this.ensureProcess(); return new Promise((resolve, reject) => { if (command.type === 'run-turn') { this.pending.set(command.requestId, { + processId: state.id, kind: 'run-turn', resolve: resolve as (messages: ChatMessageRecord[]) => void, reject, @@ -152,46 +214,56 @@ export class SidecarClient { }); } else if (command.type === 'validate-pattern') { this.pending.set(command.requestId, { + processId: state.id, kind: 'validate-pattern', resolve: resolve as (issues: unknown) => void, reject, }); } else if (command.type === 'resolve-approval') { this.pending.set(command.requestId, { + processId: state.id, kind: 'resolve-approval', resolve: resolve as () => void, reject, }); + } else if (command.type === 'cancel-turn') { + this.pending.set(command.requestId, { + processId: state.id, + kind: 'cancel-turn', + resolve: resolve as () => void, + reject, + }); } else { this.pending.set(command.requestId, { + processId: state.id, kind: 'capabilities', resolve: resolve as (capabilities: SidecarCapabilities) => void, reject, }); } - process.stdin.write(`${JSON.stringify(command)}\n`); + state.child.stdin.write(`${JSON.stringify(command)}\n`); }); } - private flushStdoutBuffer(): void { - let newlineIndex = this.stdoutBuffer.indexOf('\n'); + private flushStdoutBuffer(state: ManagedSidecarProcess): void { + let newlineIndex = state.stdoutBuffer.indexOf('\n'); while (newlineIndex >= 0) { - const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim(); - this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1); + const rawLine = state.stdoutBuffer.slice(0, newlineIndex).trim(); + state.stdoutBuffer = state.stdoutBuffer.slice(newlineIndex + 1); if (rawLine) { - this.handleEvent(JSON.parse(rawLine) as SidecarEvent); + this.handleEvent(state.id, JSON.parse(rawLine) as SidecarEvent); } - newlineIndex = this.stdoutBuffer.indexOf('\n'); + newlineIndex = state.stdoutBuffer.indexOf('\n'); } } - private handleEvent(event: SidecarEvent): void { + private handleEvent(processId: number, event: SidecarEvent): void { const pending = this.pending.get(event.requestId); - if (!pending) { + if (!pending || pending.processId !== processId) { return; } @@ -226,7 +298,11 @@ export class SidecarClient { case 'turn-complete': if (pending.kind === 'run-turn') { if (shouldHandleRunTurnEvent(pending)) { - pending.resolve(event.messages); + if (event.cancelled) { + markRunTurnPendingErrored(pending, new TurnCancelledError()); + } else { + pending.resolve(event.messages); + } } this.pending.delete(event.requestId); } @@ -240,7 +316,7 @@ export class SidecarClient { this.pending.delete(event.requestId); return; case 'command-complete': - if (pending.kind === 'resolve-approval') { + if (pending.kind === 'resolve-approval' || pending.kind === 'cancel-turn') { pending.resolve(); this.pending.delete(event.requestId); } else if (pending.kind !== 'run-turn' || pending.errored) { @@ -262,4 +338,27 @@ export class SidecarClient { } }); } + + private handleProcessClosed(state: ManagedSidecarProcess, error: Error): void { + if (state.terminated) { + return; + } + + state.terminated = true; + if (this.processState === state) { + this.processState = undefined; + } + + state.stdoutBuffer = ''; + state.resolveClosed(); + + for (const [requestId, pending] of this.pending.entries()) { + if (pending.processId !== state.id) { + continue; + } + + pending.reject(error); + this.pending.delete(requestId); + } + } } diff --git a/src/main/sidecar/turnCancelledError.ts b/src/main/sidecar/turnCancelledError.ts new file mode 100644 index 0000000..eb3652b --- /dev/null +++ b/src/main/sidecar/turnCancelledError.ts @@ -0,0 +1,6 @@ +export class TurnCancelledError extends Error { + constructor() { + super('The turn was cancelled.'); + this.name = 'TurnCancelledError'; + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0218961..e2618e2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,6 +32,7 @@ const api: ElectronApi = { setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input), setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input), sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input), + cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input), updateScratchpadSessionConfig: (input) => ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c47daae..71d93d6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -246,6 +246,7 @@ export default function App() { content = ( api.sendSessionMessage({ sessionId: selectedSession.id, content: c })} + onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }} onResolveApproval={(approvalId, decision) => api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision }) } diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index f3861e5..8b0082f 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, User } from 'lucide-react'; +import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, Square, User } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; @@ -34,6 +34,7 @@ interface ChatPaneProps { toolingSettings: WorkspaceToolingSettings; runtimeTools?: ReadonlyArray; onSend: (content: string) => Promise; + onCancelTurn?: () => void; onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise; onUpdateScratchpadConfig?: (config: { model: string; @@ -51,6 +52,7 @@ export function ChatPane({ toolingSettings, runtimeTools, onSend, + onCancelTurn, onResolveApproval, onUpdateScratchpadConfig, onUpdateSessionTooling, @@ -432,16 +434,25 @@ export function ChatPane({ >