fix(coding): preserve runtime model and failure contracts

This commit is contained in:
2026-08-23 21:21:02 +08:00
parent 52b2467d5d
commit 195979d30f
11 changed files with 427 additions and 112 deletions

View File

@@ -339,11 +339,24 @@ export class CodingConversationService {
}
async setModel(conversationId: string, model: ProductModelRef): Promise<ConversationModelState> {
const { project } = await this.projects.findActiveConversation(conversationId);
const { project, conversation } = await this.projects.findActiveConversation(conversationId);
let selected: ProductModelRef;
try {
selected = await this.runtime.validateModel(model);
} catch (error) { runtimeError(error); }
if (conversation.modelResolution === 'resolved' && conversation.model) {
await this.ensurePrepared(conversationId);
let state: ConversationModelState;
try {
state = await this.runtime.setModel({
conversationId,
accountId: selected.accountId,
modelId: selected.modelId,
});
} catch (error) { runtimeError(error); }
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
return state;
}
const state: ConversationModelState = {
model: selected,
modelResolution: 'resolved',
@@ -364,7 +377,7 @@ export class CodingConversationService {
const { project } = await this.projects.findActiveConversation(conversationId);
try {
const state = await this.runtime.setThinking({ conversationId, thinkingLevel });
await this.projects.conversationStore(project.path).setModelState(conversationId, state);
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
return state;
} catch (error) { runtimeError(error); }
}

View File

@@ -2,8 +2,6 @@ import type {
CodingConversationRuntime,
CodingRuntimeCommand,
CodingRuntimeDiagnostics,
CodingRuntimeErrorCode,
CodingRuntimePublicError,
ConversationModelState,
ConversationInteraction,
ConversationInteractionResponse,
@@ -22,22 +20,14 @@ import type {
SetConversationModelInput,
SetThinkingLevelInput,
} from './contracts';
import { CodingRuntimeContractError } from './runtime-errors';
export { CodingRuntimeContractError } from './runtime-errors';
import {
createConversationReducerState,
reduceConversationPatch,
type ConversationReducerState,
} from './conversation-reducer';
export class CodingRuntimeContractError extends Error {
readonly publicError: CodingRuntimePublicError;
constructor(code: CodingRuntimeErrorCode, message: string, recoverable: boolean) {
super(message);
this.name = 'CodingRuntimeContractError';
this.publicError = { code, message, recoverable };
}
}
export interface InMemoryConversationRuntimeOptions {
snapshots?: ConversationSnapshot[];
commands?: CodingRuntimeCommand[];

View File

@@ -38,7 +38,7 @@ import {
reduceConversationPatch,
type ConversationReducerState,
} from '../conversation-reducer';
import { CodingRuntimeContractError } from '../in-memory-conversation-runtime';
import { CodingRuntimeContractError } from '../runtime-errors';
import {
PiEventProjector,
type PiEventProjectorOptions,

View File

@@ -12,6 +12,7 @@ import type {
ConversationModelState,
PrepareConversationInput,
} from '../contracts';
import { CodingRuntimeContractError } from '../runtime-errors';
export interface PiRegisteredConversation {
projectPath: string;
@@ -22,6 +23,7 @@ export interface PiRegisteredConversation {
export interface PiSessionRegistryOptions {
projectStore: CodingProjectStore;
createConversationStore?: typeof createCodingConversationStore;
}
interface RegistryRecord extends PiRegisteredConversation {
@@ -55,11 +57,13 @@ function sameModelState(left: ConversationModelState, right: ConversationModelSt
export class PiSessionRegistry {
private readonly projectStore: CodingProjectStore;
private readonly createConversationStore: typeof createCodingConversationStore;
private readonly records = new Map<string, RegistryRecord>();
private readonly prepareFlights = new Map<string, Promise<RegistryRecord>>();
constructor(options: PiSessionRegistryOptions) {
this.projectStore = options.projectStore;
this.createConversationStore = options.createConversationStore ?? createCodingConversationStore;
}
async prepare(input: PrepareConversationInput): Promise<PiRegisteredConversation> {
@@ -71,7 +75,9 @@ export class PiSessionRegistry {
createBinding: () => Promise<PiSessionBinding>,
): Promise<PiRegisteredConversation> {
const record = await this.prepareRecord(input);
const conversation = await record.store.ensureSessionBinding(input.conversationId, createBinding);
const conversation = await this.persistWrite(
() => record.store.ensureSessionBinding(input.conversationId, createBinding),
);
record.conversation = conversation;
record.session = {
piSessionId: conversation.piSessionId as string,
@@ -86,7 +92,9 @@ export class PiSessionRegistry {
): Promise<ConversationModelState> {
const record = this.records.get(conversationId);
if (!record) throw new Error('Conversation is not registered');
record.conversation = await record.store.setModelState(conversationId, model);
record.conversation = await this.persistWrite(
() => record.store.setModelState(conversationId, model),
);
return modelStateOf(record.conversation);
}
@@ -118,7 +126,7 @@ export class PiSessionRegistry {
candidate.id === input.agentId && candidate.enabled && !candidate.archivedAt
));
if (!agent) throw new Error('Coding Agent does not exist');
const store = createCodingConversationStore(project.path);
const store = this.createConversationStore(project.path);
const conversation = await store.get(input.conversationId);
if (!conversation || conversation.agentId !== input.agentId) {
throw new Error('Coding Conversation does not exist for the selected Agent');
@@ -138,4 +146,17 @@ export class PiSessionRegistry {
this.records.set(input.conversationId, record);
return record;
}
private async persistWrite<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof CodingRuntimeContractError) throw error;
throw new CodingRuntimeContractError(
'CODING_STORAGE_WRITE_FAILED',
'Coding Conversation state could not be persisted',
true,
);
}
}
}

View File

@@ -0,0 +1,14 @@
import type {
CodingRuntimeErrorCode,
CodingRuntimePublicError,
} from './contracts';
export class CodingRuntimeContractError extends Error {
readonly publicError: CodingRuntimePublicError;
constructor(code: CodingRuntimeErrorCode, message: string, recoverable: boolean) {
super(message);
this.name = 'CodingRuntimeContractError';
this.publicError = { code, message, recoverable };
}
}