修复策划回答身份绑定与迟到恢复结果覆盖
Project CI / Repository checks (pull_request) Successful in 2m48s
Project CI / Backend tests (pull_request) Successful in 6m28s
Project CI / Native shell tests (pull_request) Failing after 13m45s
Project CI / Frontend tests (pull_request) Successful in 44m47s

在既有回合锁内核对问题与会话身份,保留已完成回合重放。
前端回答携带卡片问题标识,hydrate 写回前检查请求序列和项目路径。
补充后端与界面回归测试,同步技术合同与项目决策。
This commit is contained in:
2026-09-08 13:25:24 +00:00
parent 8d03b73582
commit 14d76419b8
5 changed files with 298 additions and 25 deletions
@@ -606,13 +606,15 @@ fn existing_turn_result_v2(
})
}
fn successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> Option<&PlanningMessageV2> {
fn successful_assistant_for_turn(
messages: &[PlanningMessageV2],
turn_index: u64,
) -> Option<&PlanningMessageV2> {
messages.iter().rev().find(|message| {
message.turn_index == turn_index
&& message.role == "assistant"
&& message.kind != "error"
&& (message.kind == "question"
&& validate_question_value_v2(&message.payload).is_ok()
&& (message.kind == "question" && validate_question_value_v2(&message.payload).is_ok()
|| message_text(message).is_some_and(|text| !text.trim().is_empty()))
})
}
@@ -656,6 +658,8 @@ fn prepare_turn_v2(
prompt: &str,
mode: Option<&str>,
is_start: bool,
expected_session_id: Option<&str>,
question_id: Option<&str>,
) -> Result<PlanningTurnStartV2, String> {
validate_project_root(root)?;
let client_turn_id = validate_client_turn_id(client_turn_id)?;
@@ -677,6 +681,9 @@ fn prepare_turn_v2(
}
};
let project_id = read_project_id_v2(root)?;
if expected_session_id.is_some_and(|expected| session.session_id != expected.trim()) {
return Err("Planning V2 Session ID 不匹配".to_string());
}
if session.project_id != project_id {
return Err("Planning V2 Session projectId 与当前项目不一致".to_string());
}
@@ -733,6 +740,16 @@ fn prepare_turn_v2(
.iter()
.rev()
.find(|message| message.client_turn_id == client_turn_id && message.role == "user");
if existing_user.is_none() {
let current_question_id = session
.current_question
.as_ref()
.and_then(|question| question.get("id"))
.and_then(Value::as_str);
if question_id != current_question_id {
return Err("待回答问题已变更,请刷新策划状态".to_string());
}
}
let llm = resolve_game_creator_llm_config_for_agent(
&load_game_creator_app_config()?,
"planning-agent-v2",
@@ -1376,8 +1393,17 @@ pub(crate) async fn start_planning_session_v2(
prompt: String,
mode: Option<String>,
) -> Result<PlanningSessionCommandResultV2, String> {
run_planning_session_v2_command(&app, project_path, None, client_turn_id, prompt, mode, true)
.await
run_planning_session_v2_command(
&app,
project_path,
None,
client_turn_id,
prompt,
mode,
true,
None,
)
.await
}
#[tauri::command]
@@ -1388,6 +1414,10 @@ pub(crate) async fn continue_planning_session_v2(
client_turn_id: String,
input: Value,
) -> Result<PlanningSessionCommandResultV2, String> {
let question_id = input
.get("questionId")
.and_then(Value::as_str)
.map(str::to_owned);
run_planning_session_v2_command(
&app,
project_path,
@@ -1396,6 +1426,7 @@ pub(crate) async fn continue_planning_session_v2(
normalize_planning_input(input)?,
None,
false,
question_id,
)
.await
}
@@ -1408,6 +1439,7 @@ async fn run_planning_session_v2_command(
prompt: String,
mode: Option<String>,
is_start: bool,
question_id: Option<String>,
) -> Result<PlanningSessionCommandResultV2, String> {
let root = PathBuf::from(project_path.trim());
enforce_project_permission_policy(&root, "conversation.read")?;
@@ -1416,14 +1448,15 @@ async fn run_planning_session_v2_command(
let client_turn_id = validate_client_turn_id(&client_turn_id)?;
let project_id = read_project_id_v2(&root)?;
let _active_guard = try_acquire_planning_v2_active(&project_id)?;
if let Some(expected) = expected_session_id.as_deref() {
let actual = read_planning_session_v2(&root)?
.ok_or_else(|| "Planning V2 Session 不存在".to_string())?;
if actual.session_id != expected.trim() {
return Err("Planning V2 Session ID 不匹配".to_string());
}
}
let start = prepare_turn_v2(&root, &client_turn_id, &prompt, mode.as_deref(), is_start)?;
let start = prepare_turn_v2(
&root,
&client_turn_id,
&prompt,
mode.as_deref(),
is_start,
expected_session_id.as_deref(),
question_id.as_deref(),
)?;
let event_app = app.clone();
run_turn_v2(&root, start, client_turn_id, prompt, move |event| {
let _ = event_app.emit("planning-session-v2-stream", event);
@@ -1462,7 +1495,9 @@ pub(crate) fn hydrate_planning_session_v2(
"planning.v2.hydrate",
) {
Ok(lock) => lock,
Err(error) if error.starts_with("项目正在被其他写操作占用:") => return Ok(None),
Err(error) if error.starts_with("项目正在被其他写操作占用:") => {
return Ok(None)
}
Err(error) => return Err(error),
};
let Some(mut session) = read_planning_session_v2(&root)? else {
@@ -1474,11 +1509,17 @@ pub(crate) fn hydrate_planning_session_v2(
session.status = "awaiting_user".to_string();
if message.kind == "question" {
session.current_question = Some(message.payload.clone());
let count = messages.iter().filter(|candidate| {
candidate.role == "assistant" && candidate.kind == "question"
&& candidate.turn_index <= session.turn_index
&& validate_question_value_v2(&candidate.payload).is_ok()
}).map(|candidate| candidate.turn_index).collect::<std::collections::BTreeSet<_>>().len() as u64;
let count = messages
.iter()
.filter(|candidate| {
candidate.role == "assistant"
&& candidate.kind == "question"
&& candidate.turn_index <= session.turn_index
&& validate_question_value_v2(&candidate.payload).is_ok()
})
.map(|candidate| candidate.turn_index)
.collect::<std::collections::BTreeSet<_>>()
.len() as u64;
session.question_count = session.question_count.max(count);
}
session.last_error = None;
@@ -1614,13 +1655,104 @@ mod tests {
fn turn_start_rides_out_a_briefly_held_project_lock() {
let (_dir, root, _session) = v2_revision_session_fixture();
let holder = hold_project_lock_briefly(&root, 120);
let start = prepare_turn_v2(&root, "turn-revise-1", "加强节奏", None, false)
let start = prepare_turn_v2(&root, "turn-revise-1", "加强节奏", None, false, None, None)
.expect("修订续跑必须等过瞬时锁争用,而不是把失败甩回总控");
holder.join().expect("lock holder thread");
assert_eq!(start.session.status, "planning");
assert!(start.replay.is_none());
}
#[test]
fn question_answer_must_target_the_persisted_question() {
let (_dir, root, mut session) = v2_revision_session_fixture();
session.status = "awaiting_user".to_string();
session.current_question = Some(serde_json::json!({"id": "question-2"}));
write_planning_session_v2(&root, &session).unwrap();
for question_id in [Some("question-1"), None] {
let result = prepare_turn_v2(
&root,
"late-answer",
"选择第一个",
None,
false,
Some(&session.session_id),
question_id,
);
assert!(matches!(result, Err(error) if error.contains("待回答问题已变更")));
let persisted = read_planning_session_v2(&root).unwrap().unwrap();
assert_eq!(persisted.status, "awaiting_user");
assert_eq!(persisted.turn_index, session.turn_index);
assert_eq!(persisted.current_question, session.current_question);
assert!(read_planning_messages_v2(&root).unwrap().is_empty());
}
let result = prepare_turn_v2(
&root,
"current-answer",
"选择第一个",
None,
false,
Some(&session.session_id),
Some("question-2"),
)
.expect("当前问题回答应正常推进");
assert_eq!(result.session.status, "planning");
}
#[test]
fn turn_rejects_a_different_session_before_writing() {
let (_dir, root, session) = v2_revision_session_fixture();
let result = prepare_turn_v2(
&root,
"wrong-session",
"加强节奏",
None,
false,
Some("old-session"),
None,
);
assert!(matches!(result, Err(error) if error.contains("Session ID 不匹配")));
assert_eq!(
read_planning_session_v2(&root).unwrap().unwrap().turn_index,
session.turn_index
);
assert!(read_planning_messages_v2(&root).unwrap().is_empty());
}
#[test]
fn completed_answer_replays_after_question_changes() {
let (_dir, root, mut session) = v2_revision_session_fixture();
session.status = "awaiting_user".to_string();
session.current_question = Some(serde_json::json!({"id": "question-2"}));
write_planning_session_v2(&root, &session).unwrap();
append_planning_message_v2(
&root,
&PlanningMessageV2 {
schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(),
message_id: "completed-answer".to_string(),
client_turn_id: "answer-1".to_string(),
turn_index: 1,
at_utc: current_plan_timestamp_utc(),
role: "assistant".to_string(),
kind: "text".to_string(),
payload: serde_json::json!({"text": "已处理"}),
},
)
.unwrap();
let result = prepare_turn_v2(
&root,
"answer-1",
"选择第一个",
None,
false,
Some(&session.session_id),
Some("question-1"),
)
.expect("已完成回答继续重放,不再次推进");
assert!(result.replay.is_some());
assert_eq!(result.session.turn_index, session.turn_index);
assert_eq!(result.session.current_question, session.current_question);
}
#[test]
fn hydrate_rides_out_a_briefly_held_project_lock() {
let (_dir, root, session) = v2_revision_session_fixture();
+26 -5
View File
@@ -782,7 +782,10 @@ export function App({
}
}
async function hydratePlanningV2Session(nextProjectPath: string) {
async function hydratePlanningV2Session(
nextProjectPath: string,
requestSequence: number,
) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath.trim()) {
return null;
@@ -795,6 +798,12 @@ export function App({
...(sessionId ? { sessionId } : {}),
},
);
if (
requestSequence !== planGddHydrateSequenceRef.current ||
localProjectPathRef.current !== nextProjectPath
) {
return null;
}
if (!result) {
planningV2SessionRef.current = null;
setPlanningV2Session(null);
@@ -822,7 +831,7 @@ export function App({
setPlanGddHydrateBusy(true);
setPlanGddError(null);
try {
await hydratePlanningV2Session(targetProjectPath);
await hydratePlanningV2Session(targetProjectPath, requestSequence);
} catch (error) {
// 项目写锁争用是瞬时的:后端已经等过一个短窗口,仍然没抢到只说明此刻
// 运行时正在写盘。这条 effect 每次监工状态变化都会再跑一次,下一拍就能
@@ -838,7 +847,10 @@ export function App({
setPlanGddError(String(error));
}
} finally {
if (requestSequence === planGddHydrateSequenceRef.current) {
if (
requestSequence === planGddHydrateSequenceRef.current &&
localProjectPathRef.current === targetProjectPath
) {
setPlanGddHydrateBusy(false);
}
}
@@ -5706,6 +5718,7 @@ export function App({
nextProjectPath: string,
prompt: string,
clientTurnId = createAgentChatRunId('planning-v2-turn'),
questionId = planningV2SessionRef.current?.session.currentQuestion?.id,
) {
const invoke = resolveTauriInvoke();
const normalizedPrompt = prompt.trim();
@@ -5732,7 +5745,10 @@ export function App({
projectPath: nextProjectPath,
sessionId: currentSessionId,
clientTurnId,
input: { text: normalizedPrompt },
input: {
text: normalizedPrompt,
...(questionId ? { questionId } : {}),
},
},
)
: await invoke<PlanningSessionCommandResultV2>(
@@ -6719,7 +6735,12 @@ export function App({
updatedAt: Date.now(),
},
]);
await executePlanningV2Turn(nextProjectPath, answer, responseId);
await executePlanningV2Turn(
nextProjectPath,
answer,
responseId,
request.questions[0].id,
);
return;
}
const runtime = projectSupervisorRuntimeRef.current;
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard';
import {
act,
App,
createPlanGddStateView,
createProjectSupervisorRuntimeHarness,
@@ -246,6 +247,106 @@ function typeComment(dialog: HTMLElement, text: string) {
}
export function registerPlanGddApprovalTests() {
it('ignores a late hydrate from the previously opened project', async () => {
const message = (text: string) => ({
role: 'assistant',
kind: 'assistant_text',
messageId: text,
atUtc: '2026-09-08T00:00:00Z',
text,
payload: { text },
});
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: {
...planningV2ApprovalResult(),
conversation: [message('当前项目的策划记录')],
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/?dev');
render(React.createElement(App, { planningStartMode: true }));
const openProject = async (path: string) => {
fireEvent.change(screen.getByLabelText('本地项目目录'), {
target: { value: path },
});
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
const pending = screen
.getByText(`创建 ${path}`)
.closest('.pending-command');
await act(async () => {
fireEvent.click(
within(pending as HTMLElement).getByRole('button', { name: '确认' }),
);
});
};
await openProject('/tmp/planning-a');
const originalInvoke = harness.invoke.getMockImplementation()!;
let finishOldHydrate!: (result: unknown) => void;
harness.invoke.mockImplementation(async (command, args) => {
if (
command === 'hydrate_planning_session_v2' &&
args?.projectPath === '/tmp/planning-a'
) {
return new Promise((resolve) => {
finishOldHydrate = resolve;
});
}
return originalInvoke(command, args);
});
fireEvent.focus(window);
await waitFor(() => expect(finishOldHydrate).toBeTypeOf('function'));
await openProject('/tmp/planning-b');
expect(screen.getByText('当前项目的策划记录')).not.toBeNull();
await act(async () => {
finishOldHydrate({
...planningV2ApprovalResult(),
conversation: [message('旧项目的迟到策划')],
});
});
expect(screen.queryByText('旧项目的迟到策划')).toBeNull();
expect(screen.getByText('当前项目的策划记录')).not.toBeNull();
});
it.each(['question', 'empty'] as const)(
'ignores a late %s hydrate after a newer hydrate has restored the GDD',
async (lateResult) => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovalResult(),
});
await mountApprovalCard(harness, true);
const originalInvoke = harness.invoke.getMockImplementation()!;
let finishOldHydrate!: (result: unknown) => void;
let deferNextHydrate = true;
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'hydrate_planning_session_v2' && deferNextHydrate) {
deferNextHydrate = false;
return new Promise((resolve) => {
finishOldHydrate = resolve;
});
}
return originalInvoke(command, args);
});
fireEvent.focus(window);
await waitFor(() => expect(finishOldHydrate).toBeTypeOf('function'));
await act(async () => {
fireEvent.focus(window);
});
expect(screen.getByLabelText('GDD 审批卡')).not.toBeNull();
await act(async () => {
finishOldHydrate(
lateResult === 'question' ? planningV2QuestionResult() : null,
);
});
expect(screen.getByLabelText('GDD 审批卡')).not.toBeNull();
expect(screen.queryByText('玩家在一局中主要反复做什么?')).toBeNull();
},
);
it('re-hydrates authority after a failed GDD decision and keeps the failure visible', async () => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovalResult(),
@@ -671,5 +772,15 @@ export function registerPlanGddApprovalTests() {
).toBe(true);
expect(screen.getByText('玩家在一局中主要反复做什么?')).not.toBeNull();
expect(screen.queryByText(/agent\.delegate/)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /持续闪避/ }));
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'continue_planning_session_v2',
expect.objectContaining({
input: { text: '持续闪避', questionId: 'core_loop' },
}),
);
});
});
}
@@ -8170,3 +8170,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 恢复只利用已经落盘的合法 question、GDD 和 approval receipt;不调用 Provider、不要求模型额外输出恢复字段、不设置复杂状态机或自动重试。
- 正常 run 进行时不读取或写入其 Planning V2 文件,也不增加文件锁或等待;无活跃 run 时恢复只做一次非阻塞锁尝试,竞争即退出,交给既有轮询。
- question 恢复为成功结果,approval 重放复用 receipt 原始 decisionId。
## 2026-09-08 Planning V2 用户输入与异步投影身份
- 问题回答携带被回答卡片的 questionId,在已有回合锁内核对 Session 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。
- hydrate 结果(包括空结果)写入前端状态前同时核对请求序列和当前项目路径;过期结果直接丢弃,不重试、不阻塞正常 run。
@@ -324,6 +324,10 @@ V2 决定状态使用 `confirmed | assumption_pending | prototype_pending``as
`input` 冻结为 `option``freeform``direct_draft``revision` 四种用户意图:
回答当前问题时,客户端必须携带被回答卡片的 `questionId`(自由文本回答同样携带),后端保留该身份,在既有回合项目锁内比较 Session ID 和当前 question ID,再开始新回合。过期或缺失的问题身份不推进回合;已完成的 `clientTurnId` 仍可重放,失败回合沿用原输入重试,修订等非问答意图不要求问题身份。此处只核对用户提交的目标,不增加恢复门禁、Provider 调用或模型输出字段。
前端 hydrate 返回后,必须在任何成功或空结果状态写回前同时比较请求序列和当前项目路径;迟到结果直接丢弃,不等待或重试。历史对话探测沿用自己的加载版本与项目路径检查。
```json
{
"kind": "option",