fix: close PI-140 cutover gaps

Back up unknown legacy Agents, reconnect settled Conversation observation, remove remaining active repository residue, and restore the bundled Python verifier import.
This commit is contained in:
2026-08-24 12:47:16 +08:00
parent 5a275b93a7
commit 7a15b1b49c
12 changed files with 286 additions and 247 deletions

View File

@@ -6,6 +6,7 @@ import {
useUserProfileStore,
} from '@/stores/user-profile';
import type { RawMessage } from '@/types/chat';
import type { ConversationSnapshot } from '@/types/coding-conversation';
import type {
AgentSessionData,
AgentSessionMessage,
@@ -338,14 +339,7 @@ async function flushUpload(key: string): Promise<void> {
}
}
/** Queue a completed local session snapshot without blocking the chat turn. */
export function queueAgentSessionSync(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
): void {
const data = buildAgentSessionData(projectId, sessionId, messages);
if (!data) return;
function queueAgentSessionData(data: AgentSessionData): void {
const userId = getCurrentAccountKey();
if (!userId) return;
@@ -356,6 +350,44 @@ export function queueAgentSessionSync(
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
}
/** Queue a completed local session snapshot without blocking the chat turn. */
export function queueAgentSessionSync(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
): void {
const data = buildAgentSessionData(projectId, sessionId, messages);
if (!data) return;
queueAgentSessionData(data);
}
/** Queue the final product Conversation snapshot after a completed prompt turn. */
export function queueCodingConversationSessionSync(snapshot: ConversationSnapshot): void {
const messages: RawMessage[] = snapshot.nodes.flatMap((node) => {
if (node.kind !== 'message') return [];
return [{
id: node.id,
role: node.role,
content: node.blocks.flatMap((block) => (
block.kind === 'text' ? [{ type: 'text', text: block.text }] : []
)),
isError: node.status === 'error' || node.status === 'aborted',
} satisfies RawMessage];
});
const settledAt = snapshot.run.settledAt;
const updatedAt = typeof settledAt === 'number' && Number.isFinite(settledAt)
? new Date(settledAt).toISOString()
: new Date().toISOString();
const data = buildAgentSessionData(
snapshot.conversation.projectId,
snapshot.conversation.id,
messages,
updatedAt,
);
if (!data?.messages.some((message) => message.role === 'assistant')) return;
queueAgentSessionData(data);
}
/** Flush persisted session snapshots after login or profile bootstrap. */
export async function flushPendingAgentSessionSync(): Promise<void> {
hydratePendingUploads();

View File

@@ -7,6 +7,7 @@ import {
type ConversationReducerState,
} from '../../shared/coding-conversation-reducer';
import { AppError } from '@/lib/error-model';
import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync';
import {
getCodingConversationSnapshot,
openCodingConversationEvents,
@@ -55,6 +56,7 @@ interface CodingConversationStoreDependencies {
openEvents(conversationId?: string): Promise<EventSource>;
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
recover(conversationId: string): Promise<void>;
queueSettledSessionSync(snapshot: ConversationSnapshot): void;
createId(kind: 'request' | 'node'): string;
preparationTimeoutMs: number;
}
@@ -277,6 +279,7 @@ function defaultDependencies(): CodingConversationStoreDependencies {
openEvents: openCodingConversationEvents,
submitPrompt: submitCodingConversationPrompt,
recover: recoverCodingConversation,
queueSettledSessionSync: queueCodingConversationSessionSync,
createId: () => crypto.randomUUID(),
preparationTimeoutMs: 10_000,
};
@@ -332,6 +335,26 @@ function validPatchBatchRange(event: CodingConversationPatchBatchEvent): boolean
));
}
function completesOrdinaryPrompt(
event: CodingConversationPatchBatchEvent,
current: ConversationSnapshot,
): boolean {
let mode = current.run.mode;
let observedActiveRun = current.run.status !== 'idle';
for (const item of event.items) {
if (item.patch.op !== 'run.state') continue;
if (item.patch.run.mode) mode = item.patch.run.mode;
if (item.patch.run.status !== 'idle') observedActiveRun = true;
if (observedActiveRun
&& mode === 'prompt'
&& item.patch.run.status === 'idle'
&& item.patch.run.terminalReason === 'completed') {
return true;
}
}
return false;
}
export function createCodingConversationStore(
dependencies: Partial<CodingConversationStoreDependencies> = {},
): StoreApi<CodingConversationStoreState> {
@@ -892,7 +915,9 @@ export function createCodingConversationStore(
}
return;
}
const shouldSyncSettledSession = completesOrdinaryPrompt(event, currentSnapshot);
let recover = false;
let applied = false;
set((state) => {
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
let reducer = current.reducer;
@@ -911,6 +936,7 @@ export function createCodingConversationStore(
}
}
if (reducer === current.reducer) return state;
applied = true;
const incomingMessages = event.items.flatMap((item) => (
item.patch.op === 'message.upsert' ? [item.patch.node] : []
));
@@ -950,6 +976,9 @@ export function createCodingConversationStore(
});
if (recover) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
} else if (applied && shouldSyncSettledSession) {
const settledSnapshot = selectCodingConversationSnapshot(event.conversationId)(get());
if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot);
}
},
}));

View File

@@ -1,218 +0,0 @@
export type SessionRunQueuedPrompt = {
id: string;
text: string;
};
export type SessionRunPhase = 'idle' | 'posting' | 'running' | 'aborting';
export type SessionRunTerminalReason = 'completed' | 'aborted' | 'failed';
export type SessionRunState<TPrompt extends SessionRunQueuedPrompt = SessionRunQueuedPrompt> = {
phase: SessionRunPhase;
runId: number | null;
promptId: string | null;
queue: TPrompt[];
terminalReason: SessionRunTerminalReason | null;
error: string | null;
suppressNextAbortError: boolean;
};
export type SessionRunEvent<TPrompt extends SessionRunQueuedPrompt = SessionRunQueuedPrompt> =
| { type: 'send_started'; runId: number; promptId: string }
| { type: 'post_accepted'; runId: number }
| { type: 'remote_busy'; runId: number }
| { type: 'remote_idle'; runId: number }
| { type: 'remote_aborted'; runId: number }
| { type: 'remote_failed'; runId: number; error: string }
| { type: 'abort_requested'; runId: number }
| { type: 'abort_confirmed'; runId: number }
| { type: 'abort_failed'; runId: number; error: string }
| { type: 'queue_prompt'; prompt: TPrompt }
| { type: 'cancel_queued_prompt'; promptId: string };
export function createIdleSessionRunState<TPrompt extends SessionRunQueuedPrompt>(
queue: TPrompt[] = [],
): SessionRunState<TPrompt> {
return {
phase: 'idle',
runId: null,
promptId: null,
queue,
terminalReason: null,
error: null,
suppressNextAbortError: false,
};
}
export function isCurrentRunEvent<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
runId: number,
): boolean {
return state.runId === runId;
}
export function isSessionRunActive<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
): boolean {
return state.phase === 'posting' || state.phase === 'running';
}
export function canStartQueuedPrompt<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
): boolean {
return state.phase === 'idle'
&& state.terminalReason !== 'failed'
&& state.queue.length > 0;
}
export function takeNextQueuedPrompt<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
): {
prompt: TPrompt | null;
state: SessionRunState<TPrompt>;
} {
const [prompt, ...queue] = state.queue;
return {
prompt: prompt ?? null,
state: {
...state,
queue,
},
};
}
export function didSuppressAbortError<TPrompt extends SessionRunQueuedPrompt>(
previousState: SessionRunState<TPrompt>,
nextState: SessionRunState<TPrompt>,
): boolean {
return previousState.suppressNextAbortError
&& !nextState.suppressNextAbortError
&& previousState.phase === nextState.phase
&& previousState.runId === nextState.runId
&& previousState.promptId === nextState.promptId
&& nextState.terminalReason === previousState.terminalReason
&& nextState.error === null;
}
export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
event: SessionRunEvent<TPrompt>,
): SessionRunState<TPrompt> {
if (event.type === 'send_started') {
return {
phase: 'posting',
runId: event.runId,
promptId: event.promptId,
queue: state.queue,
terminalReason: null,
error: null,
suppressNextAbortError: state.suppressNextAbortError,
};
}
if (event.type === 'queue_prompt') {
return {
...state,
queue: [...state.queue, event.prompt],
error: null,
};
}
if (event.type === 'cancel_queued_prompt') {
return {
...state,
queue: state.queue.filter((prompt) => prompt.id !== event.promptId),
};
}
if (!isCurrentRunEvent(state, event.runId)) {
return state;
}
if (
state.phase === 'idle'
&& state.terminalReason !== null
&& (
event.type === 'post_accepted'
|| event.type === 'remote_busy'
|| event.type === 'remote_idle'
)
) {
return state;
}
switch (event.type) {
case 'post_accepted':
return state;
case 'remote_busy':
return {
...state,
phase: 'running',
terminalReason: null,
error: null,
};
case 'remote_idle':
if (state.phase === 'posting') return state;
return {
...state,
phase: 'idle',
terminalReason: 'completed',
error: null,
suppressNextAbortError: false,
};
case 'remote_aborted':
if (state.suppressNextAbortError) {
return {
...state,
error: null,
suppressNextAbortError: false,
};
}
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'aborted',
error: null,
suppressNextAbortError: false,
};
case 'remote_failed':
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'failed',
error: event.error,
suppressNextAbortError: false,
};
case 'abort_requested':
return {
...state,
phase: 'aborting',
queue: [],
terminalReason: 'aborted',
error: null,
suppressNextAbortError: true,
};
case 'abort_confirmed':
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'aborted',
error: null,
suppressNextAbortError: false,
};
case 'abort_failed':
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'failed',
error: event.error,
suppressNextAbortError: false,
};
default:
return state;
}
}