fix: limit CE question-id drift tolerance to recovery

This commit is contained in:
Phil Larson
2026-07-01 10:00:20 -07:00
parent ad8db59374
commit 80b2dafdf7
3 changed files with 14 additions and 4 deletions

View File

@@ -188,8 +188,8 @@ export interface CreateInteractiveAiSessionOptions {
/**
* Trust the caller's persisted/current question id when answering, even if a
* rehydrated live handle generated a different question id while replaying.
* Default remains strict for planning surfaces; CE enables this because its
* persisted session row is the recovery anchor across dashboard restarts.
* Default remains strict for fresh planning/CE sessions; recovery paths may
* enable this when the persisted session row is the authoritative anchor.
*/
allowAnswerQuestionIdDrift?: boolean;
}

View File

@@ -155,6 +155,7 @@ describe("interrupt + resume (no silent loss)", () => {
expect(done.event?.type).toBe("complete");
expect(done.session.status).toBe("completed");
expect(factory).toHaveBeenCalledTimes(1);
expect(factory).toHaveBeenCalledWith(expect.objectContaining({ allowAnswerQuestionIdDrift: true }));
expect(rehydrated.prompt).toHaveBeenCalledTimes(1);
expect(rehydrated.answer).toHaveBeenCalledTimes(1);
const hasAnswerTurn = done.session.conversationHistory.some(
@@ -183,6 +184,7 @@ describe("interrupt + resume (no silent loss)", () => {
const done = await orch.answer(started.session.id, "q1", "a");
expect(done.session.status).toBe("completed");
expect(factory).toHaveBeenCalledTimes(1);
expect(factory).toHaveBeenCalledWith(expect.not.objectContaining({ allowAnswerQuestionIdDrift: true }));
expect(live.prompt).toHaveBeenCalledTimes(1);
expect(live.answer).toHaveBeenCalledTimes(1);
});
@@ -276,6 +278,7 @@ describe("interrupt + resume (no silent loss)", () => {
await new Promise((resolve) => setImmediate(resolve));
const after = store.get(created.id)!;
expect(after.status).toBe("completed");
expect(rehydrated.answer).toHaveBeenCalledTimes(1);
const hasAnswerTurn = after.conversationHistory.some(
(t) => t.text === JSON.stringify({ answer: "a", questionId: "q1" }),
);

View File

@@ -291,6 +291,7 @@ export class CeOrchestrator {
private buildSessionOptions(
stage: CeStageDefinition,
sessionId: string,
opts: { allowAnswerQuestionIdDrift?: boolean } = {},
): Parameters<CreateInteractiveAiSessionFactory>[0] {
const defaultProvider = getDefaultProvider(this.ctx.settings);
const defaultModelId = getDefaultModelId(this.ctx.settings);
@@ -302,7 +303,11 @@ export class CeOrchestrator {
tools: "coding",
requestedSkillNames: [stage.skillId],
additionalSkillPaths,
allowAnswerQuestionIdDrift: true,
/*
* FNXC:CompoundEngineering 2026-07-01-17:31:
* Question-id drift tolerance is recovery-only. Fresh CE sessions keep the strict interactive seam guard so live/DB question divergence is surfaced immediately; rehydration enables the tolerance because the persisted session row is the recovery anchor after a restart.
*/
...(opts.allowAnswerQuestionIdDrift ? { allowAnswerQuestionIdDrift: true } : {}),
onProgress: (event) => this.handleProgress(sessionId, event),
...(defaultProvider ? { defaultProvider } : {}),
...(defaultModelId ? { defaultModelId } : {}),
@@ -643,7 +648,9 @@ export class CeOrchestrator {
}
private async rehydrateReplay(session: CeSession, stage: CeStageDefinition): Promise<void> {
const interactive = await this.factory!(this.buildSessionOptions(stage, session.id));
const interactive = await this.factory!(
this.buildSessionOptions(stage, session.id, { allowAnswerQuestionIdDrift: true }),
);
const live = interactive.session;
// Walk the recorded user turns in order. The FIRST user turn is the opening