fix(pi): validate user-entry conversation forks

This commit is contained in:
2026-08-25 17:48:31 +08:00
parent 941b015330
commit 8ba2a6055c
10 changed files with 767 additions and 13 deletions

View File

@@ -491,7 +491,27 @@ export class CodingConversationService {
}
async fork(sourceConversationId: string, sourceEntryId?: string): Promise<CodingConversationV2> {
const entryId = requiredString(sourceEntryId, 'Fork source entry id', 256);
const prepared = await this.ensurePrepared(sourceConversationId);
let sourceSnapshot: ConversationSnapshot;
try {
sourceSnapshot = await this.runtime.getSnapshot(sourceConversationId);
} catch (error) {
runtimeError(error);
}
const sourceNode = sourceSnapshot.nodes.find((node) => (
node.kind === 'message'
&& node.sourceEntryId === entryId
&& node.role === 'user'
&& node.status !== 'optimistic'
));
if (!sourceNode) {
throw new CodingConversationServiceError(
400,
'CODING_CONVERSATION_REQUEST_INVALID',
'Fork source must be a durable user message on the active Conversation path',
);
}
const source = await this.projects.findActiveConversation(sourceConversationId);
const store = this.projects.conversationStore(source.project.path);
const created = await persist(() => store.create({
@@ -503,7 +523,7 @@ export class CodingConversationService {
try {
await this.runtime.fork({
sourceConversationId,
...(sourceEntryId ? { sourceEntryId } : {}),
sourceEntryId: entryId,
conversation: {
...prepared,
conversationId: created.id,

View File

@@ -104,10 +104,12 @@ type ProxyCompositionRun = {
provider: LocalProofProvider;
projectId: string;
projectPath: string;
sourceConversationId?: string;
hostToken: string;
originalAcceptPrompt: CodingProductComposition['conversations']['acceptPrompt'];
submissionInjection: { definiteRejections: number };
activeStatus?: PiReleaseProxyCompositionStatus;
settledStatus?: PiReleaseProxyCompositionStatus;
};
type ResilienceCompositionRun = {
@@ -212,6 +214,18 @@ export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositio
definiteRejectionObserved: boolean;
retryAccepted: boolean;
};
fork: {
conversationCount: number;
sameAgent: boolean;
targetBindingEstablished: boolean;
distinctSessionBinding: boolean;
workerStatus: string;
runStatus: string;
sourceEntryRole: 'user';
targetHydratedBeforeSourceEntry: boolean;
sourceUnchanged: boolean;
promptReplayObserved: boolean;
};
currentHostTokenUsedBy: ProofWorkerRole[];
released: { workers: number };
}
@@ -916,8 +930,12 @@ async function proxyCompositionStatus(
.filter(({ role }) => role === 'child')
.map(({ processId }) => processId)
.sort((left, right) => left - right);
const conversations = await run.composition.conversations.listConversations(run.projectId);
if (!run.sourceConversationId && conversations.length === 1) {
run.sourceConversationId = conversations[0]?.id;
}
return {
conversationCount: (await run.composition.conversations.listConversations(run.projectId)).length,
conversationCount: conversations.length,
providerRequests,
activeProviderRequests: run.provider.activeCounts(),
workers: diagnostics.workers,
@@ -1442,6 +1460,12 @@ export async function getFinalAsarProxyCompositionStatus(): Promise<PiReleasePro
&& status.processes.parent.length > 0) {
run.activeStatus = status;
}
if (status.conversationCount === 1
&& status.activeProviderRequests.parent === 0
&& status.activeProviderRequests.child === 0
&& status.providerCompatibility.systemRoleRequests >= 2) {
run.settledStatus = status;
}
return status;
}
@@ -1456,10 +1480,12 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
if (!run) throw new Error('PI proxy composition proof is not running');
try {
const conversations = await run.composition.conversations.listConversations(run.projectId);
if (conversations.length !== 1) {
throw new Error(`Expected one first proxy Conversation, received ${conversations.length}`);
if (conversations.length !== 2 || !run.sourceConversationId) {
throw new Error(`Expected source and forked proxy Conversations, received ${conversations.length}`);
}
const conversation = conversations[0]!;
const conversation = conversations.find(({ id }) => id === run.sourceConversationId);
const forked = conversations.find(({ id }) => id !== run.sourceConversationId);
if (!conversation || !forked) throw new Error('Proxy fork source or target Conversation is unavailable');
let snapshot = await run.composition.conversations.getSnapshot(conversation.id);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline && (
@@ -1473,9 +1499,24 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
if (snapshot.run.status !== 'idle' || !serializedNodes.includes('REAL_PARENT_COMPLETE')) {
throw new Error('First proxy Conversation did not settle with the controlled parent response');
}
const binding = await run.composition.projects.conversationStore(run.projectPath).get(conversation.id);
const forkedSnapshot = await run.composition.conversations.getSnapshot(forked.id);
const sourceEntry = snapshot.nodes.find((node) => (
node.kind === 'message'
&& node.role === 'user'
&& node.blocks.some((block) => block.kind === 'text'
&& block.text.includes('Retry the packaged Main proxy Conversation'))
));
if (!sourceEntry || sourceEntry.kind !== 'message' || !sourceEntry.sourceEntryId) {
throw new Error('Proxy source user entry is not durable after the controlled turn');
}
const [binding, forkedBinding] = await Promise.all([
run.composition.projects.conversationStore(run.projectPath).get(conversation.id),
run.composition.projects.conversationStore(run.projectPath).get(forked.id),
]);
const finalStatus = await proxyCompositionStatus(run);
const activeStatus = run.activeStatus ?? finalStatus;
const settledStatus = run.settledStatus;
if (!settledStatus) throw new Error('Proxy source Conversation did not record a settled pre-fork baseline');
const currentHostTokenUsedBy: ProofWorkerRole[] = [
...(activeStatus.providerRequests.parent > 0 ? ['parent' as const] : []),
...(activeStatus.providerRequests.child > 0 ? ['child' as const] : []),
@@ -1486,6 +1527,27 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
if (!binding?.piSessionId || !binding.sessionKey) {
throw new Error('First proxy Conversation did not persist its Pi session binding');
}
if (!forkedBinding?.piSessionId || !forkedBinding.sessionKey) {
throw new Error('Forked proxy Conversation did not persist its Pi session binding');
}
const distinctSessionBinding = binding.piSessionId !== forkedBinding.piSessionId
&& binding.sessionKey !== forkedBinding.sessionKey;
const targetHydratedBeforeSourceEntry = forkedSnapshot.nodes.every((node) => (
node.kind !== 'message' || node.sourceEntryId !== sourceEntry.sourceEntryId
)) && !JSON.stringify(forkedSnapshot.nodes).includes('REAL_PARENT_COMPLETE');
if (forked.agentId !== conversation.agentId
|| !distinctSessionBinding
|| forkedSnapshot.worker.status !== 'ready'
|| forkedSnapshot.run.status !== 'idle'
|| !targetHydratedBeforeSourceEntry) {
throw new Error(`Proxy user-entry fork proof failed: ${JSON.stringify({
sameAgent: forked.agentId === conversation.agentId,
distinctSessionBinding,
workerStatus: forkedSnapshot.worker.status,
runStatus: forkedSnapshot.run.status,
targetHydratedBeforeSourceEntry,
})}`);
}
if (!finalStatus.providerCompatibility.roleContract.developerRejected
|| !finalStatus.providerCompatibility.roleContract.systemAccepted
|| finalStatus.providerCompatibility.developerRoleRequests !== 0
@@ -1512,7 +1574,7 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
tokenSafety,
providerMode: 'loopback-through-authenticated-host-proxy',
firstConversation: {
count: conversations.length,
count: 1,
bindingEstablished: true,
workerStatus: snapshot.worker.status,
runStatus: snapshot.run.status,
@@ -1521,6 +1583,20 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
definiteRejectionObserved: finalStatus.providerCompatibility.controlledDefiniteRejections === 1,
retryAccepted: finalStatus.providerCompatibility.systemRoleRequests >= 2,
},
fork: {
conversationCount: conversations.length,
sameAgent: forked.agentId === conversation.agentId,
targetBindingEstablished: true,
distinctSessionBinding,
workerStatus: forkedSnapshot.worker.status,
runStatus: forkedSnapshot.run.status,
sourceEntryRole: 'user',
targetHydratedBeforeSourceEntry,
sourceUnchanged: serializedNodes.includes('Retry the packaged Main proxy Conversation')
&& serializedNodes.includes('REAL_PARENT_COMPLETE'),
promptReplayObserved: finalStatus.providerRequests.parent !== settledStatus.providerRequests.parent
|| finalStatus.providerRequests.child !== settledStatus.providerRequests.child,
},
currentHostTokenUsedBy,
};
await cleanupProxyCompositionRun(run);