diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 4164a14..d022611 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -156,6 +156,11 @@ function isSidecarStoppedBeforeCompletionError(error: unknown): error is Error { return error instanceof Error && error.message === SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE; } +const INTERRUPTED_RUN_ERROR = + 'This session was interrupted because Aryx restarted while a run was in progress.'; +const INTERRUPTED_APPROVAL_ERROR = + 'Pending approval was interrupted because Aryx restarted before a decision was recorded.'; + export class AryxAppService extends EventEmitter { private readonly workspaceRepository = new WorkspaceRepository(); private readonly sidecar = new SidecarClient(); @@ -195,7 +200,7 @@ export class AryxAppService extends EventEmitter { || didSyncProjectTooling || didPruneSelections || didPruneApprovalTools - || this.failInterruptedPendingApprovals(this.workspace) + || this.cleanupInterruptedSessions(this.workspace) ) { await this.workspaceRepository.save(this.workspace); } @@ -2015,33 +2020,41 @@ export class AryxAppService extends EventEmitter { this.emit('session-event', event); } - private failInterruptedPendingApprovals(workspace: WorkspaceState): boolean { + private cleanupInterruptedSessions(workspace: WorkspaceState): boolean { let changed = false; for (const session of workspace.sessions) { const pendingApprovals = listPendingApprovals(session); - if (pendingApprovals.length === 0) { + const hasPendingUserInput = session.pendingUserInput !== undefined; + const failedRequestIds = new Set( + session.runs + .filter((run) => run.status === 'running') + .map((run) => run.requestId), + ); + if (pendingApprovals.length === 0 && !hasPendingUserInput && failedRequestIds.size === 0) { continue; } changed = true; const failedAt = nowIso(); - const error = 'Pending approval was interrupted because Aryx restarted before a decision was recorded.'; - const requestIds = this.rejectPendingApprovals(session, failedAt, error); - session.status = 'error'; - session.lastError = error; - session.updatedAt = failedAt; - - if (requestIds.length === 0) { - const fallbackRequestId = session.runs.find((run) => run.status === 'running')?.requestId; - if (fallbackRequestId) { - requestIds.push(fallbackRequestId); + if (pendingApprovals.length > 0) { + for (const requestId of this.rejectPendingApprovals(session, failedAt, INTERRUPTED_APPROVAL_ERROR)) { + failedRequestIds.add(requestId); } } - for (const requestId of requestIds) { + if (session.pendingUserInput) { + this.pendingUserInputHandles.delete(session.pendingUserInput.id); + session.pendingUserInput = undefined; + } + + session.status = 'error'; + session.lastError = INTERRUPTED_RUN_ERROR; + session.updatedAt = failedAt; + + for (const requestId of failedRequestIds) { this.updateSessionRun(session, requestId, (run) => - failSessionRunRecord(run, failedAt, error)); + failSessionRunRecord(run, failedAt, INTERRUPTED_RUN_ERROR)); } } diff --git a/tests/main/appServiceInterruptedSessions.test.ts b/tests/main/appServiceInterruptedSessions.test.ts new file mode 100644 index 0000000..7d17cca --- /dev/null +++ b/tests/main/appServiceInterruptedSessions.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import type { PendingApprovalRecord } from '@shared/domain/approval'; +import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth'; +import type { PatternDefinition } from '@shared/domain/pattern'; +import type { PendingPlanReviewRecord } from '@shared/domain/planReview'; +import type { SessionRunRecord } from '@shared/domain/runTimeline'; +import type { SessionRecord } from '@shared/domain/session'; +import type { PendingUserInputRecord } from '@shared/domain/userInput'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +const TIMESTAMP = '2026-03-28T00:00:00.000Z'; +const INTERRUPTED_RUN_ERROR = + 'This session was interrupted because Aryx restarted while a run was in progress.'; + +mock.module('electron', () => { + const electronMock = { + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx', + getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures', + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, + shell: { + openPath: async () => '', + }, + }; + + return { + ...electronMock, + default: electronMock, + }; +}); + +mock.module('keytar', () => ({ + default: { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => false, + }, +})); + +const { AryxAppService } = await import('@main/AryxAppService'); + +function requireSinglePattern(workspace: WorkspaceState): PatternDefinition { + const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single'); + if (!pattern) { + throw new Error('Expected the workspace seed to include a single-agent pattern.'); + } + + return pattern; +} + +function createSession(patternId: string, overrides?: Partial): SessionRecord { + return { + id: 'session-1', + projectId: 'project-1', + patternId, + title: 'Session', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + status: 'idle', + messages: [], + runs: [], + ...overrides, + }; +} + +function createRun( + pattern: PatternDefinition, + overrides?: Partial, +): SessionRunRecord { + return { + id: 'run-1', + requestId: 'request-1', + projectId: 'project-1', + projectPath: 'C:\\workspace\\personal\\repositories\\aryx', + workspaceKind: 'project', + patternId: pattern.id, + patternName: pattern.name, + patternMode: pattern.mode, + triggerMessageId: 'message-1', + startedAt: TIMESTAMP, + status: 'running', + agents: [], + events: [ + { + id: 'run-event-1', + kind: 'run-started', + occurredAt: TIMESTAMP, + status: 'completed', + }, + ], + ...overrides, + }; +} + +function createPendingApproval(id: string, title: string): PendingApprovalRecord { + return { + id, + kind: 'tool-call', + status: 'pending', + requestedAt: TIMESTAMP, + title, + }; +} + +function createPendingUserInput(overrides?: Partial): PendingUserInputRecord { + return { + id: 'user-input-1', + status: 'pending', + question: 'What should I do next?', + allowFreeform: true, + requestedAt: TIMESTAMP, + ...overrides, + }; +} + +function createPendingPlanReview(overrides?: Partial): PendingPlanReviewRecord { + return { + id: 'plan-review-1', + status: 'pending', + summary: 'Review the plan before continuing.', + planContent: '1. Investigate\n2. Implement', + requestedAt: TIMESTAMP, + ...overrides, + }; +} + +function createPendingMcpAuth(overrides?: Partial): PendingMcpAuthRecord { + return { + id: 'oauth-1', + status: 'pending', + serverName: 'Example MCP', + serverUrl: 'https://example.com/mcp', + requestedAt: TIMESTAMP, + ...overrides, + }; +} + +function createService( + workspace: WorkspaceState, +): { + service: InstanceType; + getSaveCalls: () => number; +} { + let saveCalls = 0; + const service = new AryxAppService(); + const internals = service as unknown as Record; + + internals.workspaceRepository = { + load: async () => workspace, + save: async () => { + saveCalls += 1; + }, + }; + internals.syncUserDiscoveredTooling = async () => false; + internals.syncProjectDiscoveredTooling = async () => false; + internals.pruneUnavailableSessionToolingSelections = () => false; + internals.pruneUnavailableApprovalTools = async () => false; + internals.refreshProjectGitContext = async () => undefined; + + return { + service, + getSaveCalls: () => saveCalls, + }; +} + +describe('AryxAppService interrupted session cleanup', () => { + test('clears stale approvals and user input, then fails the interrupted run on load', async () => { + const workspace = createWorkspaceSeed(); + const pattern = requireSinglePattern(workspace); + const runningRun = createRun(pattern); + const session = createSession(pattern.id, { + status: 'running', + pendingApproval: createPendingApproval('approval-1', 'Approve reading the repo'), + pendingApprovalQueue: [createPendingApproval('approval-2', 'Approve writing the repo')], + pendingUserInput: createPendingUserInput(), + runs: [runningRun], + }); + workspace.sessions = [session]; + + const { service, getSaveCalls } = createService(workspace); + + const loaded = await service.loadWorkspace(); + const cleanedSession = loaded.sessions[0]; + if (!cleanedSession) { + throw new Error('Expected loadWorkspace to return the interrupted session.'); + } + + expect(cleanedSession.pendingApproval).toBeUndefined(); + expect(cleanedSession.pendingApprovalQueue).toBeUndefined(); + expect(cleanedSession.pendingUserInput).toBeUndefined(); + expect(cleanedSession.status).toBe('error'); + expect(cleanedSession.lastError).toBe(INTERRUPTED_RUN_ERROR); + expect(getSaveCalls()).toBe(1); + + const failedRun = cleanedSession.runs[0]; + if (!failedRun) { + throw new Error('Expected the interrupted run to remain attached to the session.'); + } + + expect(failedRun.status).toBe('error'); + expect(failedRun.completedAt).toBe(cleanedSession.updatedAt); + expect(failedRun.events.some((event) => event.kind === 'run-failed' && event.error === INTERRUPTED_RUN_ERROR)) + .toBe(true); + expect(failedRun.events.filter((event) => event.kind === 'approval').map((event) => event.decision)) + .toEqual(['rejected', 'rejected']); + }); + + test('fails sessions that were left in running state even without pending interaction records', async () => { + const workspace = createWorkspaceSeed(); + const pattern = requireSinglePattern(workspace); + const session = createSession(pattern.id, { + status: 'running', + runs: [createRun(pattern)], + }); + workspace.sessions = [session]; + + const { service, getSaveCalls } = createService(workspace); + + const loaded = await service.loadWorkspace(); + const cleanedSession = loaded.sessions[0]; + if (!cleanedSession) { + throw new Error('Expected loadWorkspace to return the running session.'); + } + + expect(cleanedSession.status).toBe('error'); + expect(cleanedSession.lastError).toBe(INTERRUPTED_RUN_ERROR); + expect(cleanedSession.runs[0]?.status).toBe('error'); + expect(getSaveCalls()).toBe(1); + }); + + test('preserves restart-safe plan review and MCP auth prompts', async () => { + const workspace = createWorkspaceSeed(); + const pattern = requireSinglePattern(workspace); + const planReview = createPendingPlanReview(); + const mcpAuth = createPendingMcpAuth(); + const session = createSession(pattern.id, { + pendingPlanReview: planReview, + pendingMcpAuth: mcpAuth, + }); + workspace.sessions = [session]; + + const { service, getSaveCalls } = createService(workspace); + + const loaded = await service.loadWorkspace(); + const cleanedSession = loaded.sessions[0]; + if (!cleanedSession) { + throw new Error('Expected loadWorkspace to return the session.'); + } + + expect(cleanedSession.status).toBe('idle'); + expect(cleanedSession.pendingPlanReview).toEqual(planReview); + expect(cleanedSession.pendingMcpAuth).toEqual(mcpAuth); + expect(getSaveCalls()).toBe(0); + }); +});