mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-26 14:38:45 +02:00
fix: clean up interrupted session state on restart
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+28
-15
@@ -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<AppServiceEvents> {
|
||||
private readonly workspaceRepository = new WorkspaceRepository();
|
||||
private readonly sidecar = new SidecarClient();
|
||||
@@ -195,7 +200,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|| 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<AppServiceEvents> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>): 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>,
|
||||
): 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>): PendingUserInputRecord {
|
||||
return {
|
||||
id: 'user-input-1',
|
||||
status: 'pending',
|
||||
question: 'What should I do next?',
|
||||
allowFreeform: true,
|
||||
requestedAt: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingPlanReview(overrides?: Partial<PendingPlanReviewRecord>): 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>): PendingMcpAuthRecord {
|
||||
return {
|
||||
id: 'oauth-1',
|
||||
status: 'pending',
|
||||
serverName: 'Example MCP',
|
||||
serverUrl: 'https://example.com/mcp',
|
||||
requestedAt: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
workspace: WorkspaceState,
|
||||
): {
|
||||
service: InstanceType<typeof AryxAppService>;
|
||||
getSaveCalls: () => number;
|
||||
} {
|
||||
let saveCalls = 0;
|
||||
const service = new AryxAppService();
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user