fix(pi): retain ownership for uncertain mutations

This commit is contained in:
2026-08-25 23:53:54 +08:00
parent 621ebb1781
commit 019cbb115a
21 changed files with 1113 additions and 67 deletions

View File

@@ -426,6 +426,16 @@ function runIsTerminal(status: ConversationSnapshot['run']['status']): boolean {
return status === 'idle' || status === 'error';
}
const REQUEST_UNCERTAIN_MESSAGE = '请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。';
function requestUncertainError(): CodingRuntimeContractError {
return new CodingRuntimeContractError(
'CODING_REQUEST_UNCERTAIN',
REQUEST_UNCERTAIN_MESSAGE,
true,
);
}
function publicWorkerState(state: PiWorkerPoolState): ConversationSnapshot['worker'] {
if (state.state === 'spawning') return { status: 'starting', generation: state.generation };
if (state.state === 'crashed') {
@@ -460,7 +470,7 @@ function runtimeFailure(error: unknown): CodingRuntimePublicError {
if (error.code === 'PI_RPC_TIMEOUT') {
return {
code: 'CODING_REQUEST_UNCERTAIN',
message: 'The local Agent did not confirm the request',
message: REQUEST_UNCERTAIN_MESSAGE,
recoverable: true,
};
}
@@ -626,6 +636,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
async prompt(input: PromptConversationInput): Promise<PromptAcceptance> {
await this.waitForProjection(input.conversationId);
this.assertNoUncertainMutation(input.conversationId);
if (input.mode === 'steer') {
const acceptance = await this.steer(input);
return {
@@ -765,6 +776,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
async setModel(input: SetConversationModelInput): Promise<ConversationModelState> {
await this.waitForProjection(input.conversationId);
this.assertNoUncertainMutation(input.conversationId);
const snapshot = this.snapshot(input.conversationId);
const thinkingLevel = snapshot.conversation.model.model?.thinkingLevel ?? 'off';
const selection = await this.resolveModel({
@@ -825,6 +837,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
async setThinking(input: SetThinkingLevelInput): Promise<ConversationModelState> {
await this.waitForProjection(input.conversationId);
this.assertNoUncertainMutation(input.conversationId);
const current = this.snapshot(input.conversationId).conversation.model;
if (!current.model) {
throw new CodingRuntimeContractError(
@@ -876,6 +889,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
async compact(conversationId: string): Promise<void> {
await this.waitForProjection(conversationId);
this.assertNoUncertainMutation(conversationId);
const runId = this.id('run');
this.acquireRunBackgroundLease(conversationId, runId);
const generation = this.pool.getState(conversationId)?.generation;
@@ -905,6 +919,11 @@ export class PiConversationRuntime implements CodingConversationRuntime {
try {
await ticket.accepted;
} catch (error) {
if (error instanceof PiProcessError && error.code === 'PI_RPC_TIMEOUT') {
const failure = requestUncertainError();
this.markRunUncertain(conversationId, runId, failure.publicError);
throw failure;
}
await this.failRun(conversationId, runId, error);
throw error;
}
@@ -912,6 +931,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
async fork(input: ForkConversationInput): Promise<ForkResult> {
await this.waitForProjection(input.sourceConversationId);
this.assertNoUncertainMutation(input.sourceConversationId);
this.snapshot(input.sourceConversationId);
const registered = await this.registry.prepare(input.conversation);
const canonicalInput: PrepareConversationInput = {
@@ -1091,6 +1111,14 @@ export class PiConversationRuntime implements CodingConversationRuntime {
return Boolean(snapshot && !runIsTerminal(snapshot.run.status));
}
private assertNoUncertainMutation(conversationId: string): void {
const run = this.states.get(conversationId)?.snapshot?.run;
if (!runIsTerminal(run?.status ?? 'idle')
&& run?.error?.code === 'CODING_REQUEST_UNCERTAIN') {
throw requestUncertainError();
}
}
markProviderStale(): void {
this.pool.markProviderStale();
}
@@ -1137,6 +1165,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
input: QueueMessageInput,
): Promise<QueueAcceptance> {
await this.waitForProjection(input.conversationId);
this.assertNoUncertainMutation(input.conversationId);
const snapshot = this.snapshot(input.conversationId);
if (snapshot.run.runId) {
this.acquireRunBackgroundLease(input.conversationId, snapshot.run.runId);
@@ -1255,6 +1284,11 @@ export class PiConversationRuntime implements CodingConversationRuntime {
true,
)
: error;
if (failure instanceof PiProcessError && failure.code === 'PI_RPC_TIMEOUT') {
const uncertain = requestUncertainError();
this.markRunUncertain(conversationId, runId, uncertain.publicError);
throw uncertain;
}
this.pool.failTopLevel(
conversationId,
runId,
@@ -1265,6 +1299,19 @@ export class PiConversationRuntime implements CodingConversationRuntime {
}
}
private markRunUncertain(
conversationId: string,
runId: string,
error: CodingRuntimePublicError,
): void {
const current = this.states.get(conversationId)?.snapshot?.run;
if (!current || current.runId !== runId || runIsTerminal(current.status)) return;
this.emit(conversationId, {
op: 'run.state',
run: { ...current, error: clone(error) },
}, runId);
}
private snapshot(conversationId: string): ConversationSnapshot {
const snapshot = this.states.get(conversationId)?.snapshot;
if (!snapshot) {
@@ -1314,6 +1361,40 @@ export class PiConversationRuntime implements CodingConversationRuntime {
}
private onPoolEvent(event: PiWorkerPoolEvent): void {
if (event.type === 'top-level.confirmed') {
void this.enqueueProjection(event.conversationId, async () => {
const snapshot = this.states.get(event.conversationId)?.snapshot;
const current = snapshot?.run;
if (!snapshot
|| snapshot.cursor.workerGeneration !== event.generation
|| current?.runId !== event.runId
|| runIsTerminal(current.status)) return;
const { error: _error, ...confirmed } = current;
this.emit(event.conversationId, {
op: 'run.state',
run: {
...confirmed,
status: confirmed.status === 'queued' ? 'running' : confirmed.status,
},
}, event.runId);
}).catch((error) => {
this.recordProjectionFailure(event.conversationId, event.generation, error);
});
return;
}
if (event.type === 'top-level.failed') {
void this.enqueueProjection(event.conversationId, async () => {
await this.failRun(
event.conversationId,
event.runId,
event.error,
event.generation,
);
}).catch((error) => {
this.recordProjectionFailure(event.conversationId, event.generation, error);
});
return;
}
if (event.type === 'worker.replaced') {
if (!this.states.has(event.conversationId)) return;
this.replaceWorkerGeneration(event.conversationId, event.state, true);