Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e553bb15d |
@@ -2829,8 +2829,6 @@ impl CodexAppServerConnection {
|
|||||||
callback(&platform_llm::LlmStreamDelta {
|
callback(&platform_llm::LlmStreamDelta {
|
||||||
accumulated_text: streamed_text.clone(),
|
accumulated_text: streamed_text.clone(),
|
||||||
delta_text: delta,
|
delta_text: delta,
|
||||||
accumulated_reasoning: String::new(),
|
|
||||||
reasoning_delta: String::new(),
|
|
||||||
finish_reason: None,
|
finish_reason: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3135,7 +3133,6 @@ fn parse_game_creator_codex_app_server_text(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
},
|
},
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some(thread_id.to_string()),
|
response_id: Some(thread_id.to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -589,7 +589,6 @@ fn parse_game_creator_codex_cli_response(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
},
|
},
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id,
|
response_id,
|
||||||
usage,
|
usage,
|
||||||
|
|||||||
@@ -104,17 +104,6 @@ fn design_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn design_reasoning_event(
|
|
||||||
root: &Path,
|
|
||||||
turn_id: &str,
|
|
||||||
id: Option<&str>,
|
|
||||||
reasoning: String,
|
|
||||||
) -> DesignEvent {
|
|
||||||
let mut event = design_event(root, turn_id, "reasoning", id, None, None);
|
|
||||||
event.reasoning_text = Some(reasoning);
|
|
||||||
event
|
|
||||||
}
|
|
||||||
|
|
||||||
fn design_project_id(root: &Path) -> Result<String, String> {
|
fn design_project_id(root: &Path) -> Result<String, String> {
|
||||||
validate_project_root(root)?;
|
validate_project_root(root)?;
|
||||||
Ok(read_existing_manifest_for_project(root)?.project_id)
|
Ok(read_existing_manifest_for_project(root)?.project_id)
|
||||||
@@ -473,7 +462,6 @@ fn build_design_request(
|
|||||||
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
|
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
|
||||||
.with_web_search(false);
|
.with_web_search(false);
|
||||||
apply_game_creator_llm_reasoning_effort(request, llm)
|
apply_game_creator_llm_reasoning_effort(request, llm)
|
||||||
.map(|request| request.with_reasoning_capture(true))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
||||||
@@ -560,12 +548,6 @@ async fn request_design_provider(
|
|||||||
Some(String::new()),
|
Some(String::new()),
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
let result = if llm.stream {
|
let result = if llm.stream {
|
||||||
let mut stream_sequence = 0_u64;
|
let mut stream_sequence = 0_u64;
|
||||||
client
|
client
|
||||||
@@ -583,30 +565,18 @@ async fn request_design_provider(
|
|||||||
"model": llm.model,
|
"model": llm.model,
|
||||||
"deltaChars": delta.delta_text.chars().count(),
|
"deltaChars": delta.delta_text.chars().count(),
|
||||||
"accumulatedChars": delta.accumulated_text.chars().count(),
|
"accumulatedChars": delta.accumulated_text.chars().count(),
|
||||||
"reasoningDeltaChars": delta.reasoning_delta.chars().count(),
|
|
||||||
"reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(),
|
|
||||||
"deltaText": delta.delta_text,
|
"deltaText": delta.delta_text,
|
||||||
"finishReason": delta.finish_reason,
|
"finishReason": delta.finish_reason,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if !delta.delta_text.is_empty() || delta.finish_reason.is_some() {
|
emit(design_event(
|
||||||
emit(design_event(
|
root,
|
||||||
root,
|
&turn_id,
|
||||||
&turn_id,
|
"text",
|
||||||
"text",
|
Some(&message_id),
|
||||||
Some(&message_id),
|
Some(delta.accumulated_text.clone()),
|
||||||
Some(delta.accumulated_text.clone()),
|
None,
|
||||||
None,
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
if !delta.reasoning_delta.is_empty() {
|
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
delta.accumulated_reasoning.clone(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -614,14 +584,6 @@ async fn request_design_provider(
|
|||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if !response.reasoning.is_empty() {
|
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
response.reasoning.clone(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
design_debug(
|
design_debug(
|
||||||
root,
|
root,
|
||||||
"response",
|
"response",
|
||||||
@@ -644,12 +606,6 @@ async fn request_design_provider(
|
|||||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
return Err(detail);
|
return Err(detail);
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(
|
tokio::time::sleep(Duration::from_millis(
|
||||||
@@ -698,24 +654,8 @@ async fn request_scripted_design_provider(
|
|||||||
Some(String::new()),
|
Some(String::new()),
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
match fake_provider::take() {
|
match fake_provider::take() {
|
||||||
Some(Ok(response)) => {
|
Some(Ok(response)) => return Ok(response),
|
||||||
if !response.reasoning.is_empty() {
|
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
response.reasoning.clone(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
Some(Err(error)) => {
|
Some(Err(error)) => {
|
||||||
let detail = redact_agent_runtime_error(
|
let detail = redact_agent_runtime_error(
|
||||||
root,
|
root,
|
||||||
@@ -726,24 +666,10 @@ async fn request_scripted_design_provider(
|
|||||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
return Err(detail);
|
return Err(detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => return Err("假 Provider 脚本耗尽".into()),
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
return Err("假 Provider 脚本耗尽".into());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unreachable!()
|
unreachable!()
|
||||||
@@ -1359,7 +1285,6 @@ mod tests {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "fake-design".into(),
|
model: "fake-design".into(),
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some(if calls.is_empty() {
|
finish_reason: Some(if calls.is_empty() {
|
||||||
"stop".into()
|
"stop".into()
|
||||||
} else {
|
} else {
|
||||||
@@ -1420,69 +1345,6 @@ mod tests {
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
|
|
||||||
let session = new_design_session("project", "quality");
|
|
||||||
let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default())
|
|
||||||
.expect("design request");
|
|
||||||
assert!(request.capture_reasoning);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
|
|
||||||
let (_temp, root, _resources) = init_design_project();
|
|
||||||
let mut session = new_design_session("design-fake", "quality");
|
|
||||||
begin_design_turn(&mut session, "turn-reasoning");
|
|
||||||
let mut response = fake_response("reasoning", "正文", Vec::new());
|
|
||||||
response.reasoning = "先分析需求,再组织方案。".into();
|
|
||||||
let _fake = fake_provider::install(vec![Ok(response)], 0);
|
|
||||||
let mut events = Vec::new();
|
|
||||||
let response =
|
|
||||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
|
||||||
.await
|
|
||||||
.expect("scripted provider");
|
|
||||||
|
|
||||||
let reasoning_events = events
|
|
||||||
.iter()
|
|
||||||
.filter_map(|event| event.reasoning_text.as_deref())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]);
|
|
||||||
assert_eq!(response.text, "正文");
|
|
||||||
assert!(session.history.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() {
|
|
||||||
let (_temp, root, _resources) = init_design_project();
|
|
||||||
let mut session = new_design_session("design-fake", "quality");
|
|
||||||
begin_design_turn(&mut session, "turn-reasoning-retry");
|
|
||||||
let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new());
|
|
||||||
response.reasoning = "重试后的推理".into();
|
|
||||||
let _fake = fake_provider::install(
|
|
||||||
vec![
|
|
||||||
Err(platform_llm::LlmError::Upstream {
|
|
||||||
status_code: 503,
|
|
||||||
message: "busy".into(),
|
|
||||||
}),
|
|
||||||
Ok(response),
|
|
||||||
],
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
let mut events = Vec::new();
|
|
||||||
let response =
|
|
||||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
|
||||||
.await
|
|
||||||
.expect("scripted retry provider");
|
|
||||||
|
|
||||||
let reasoning_events = events
|
|
||||||
.iter()
|
|
||||||
.filter_map(|event| event.reasoning_text.as_deref())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]);
|
|
||||||
assert_eq!(response.text, "重试后的正文");
|
|
||||||
assert!(session.history.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn fake_provider_walks_five_phases_and_enters_consultant() {
|
async fn fake_provider_walks_five_phases_and_enters_consultant() {
|
||||||
let (_temp, root, resources) = init_design_project();
|
let (_temp, root, resources) = init_design_project();
|
||||||
|
|||||||
@@ -409,8 +409,6 @@ where
|
|||||||
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
||||||
accumulated_text,
|
accumulated_text,
|
||||||
delta_text,
|
delta_text,
|
||||||
accumulated_reasoning: String::new(),
|
|
||||||
reasoning_delta: String::new(),
|
|
||||||
finish_reason,
|
finish_reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -501,7 +499,6 @@ mod tests {
|
|||||||
provider: LlmProvider::OpenAiCompatible,
|
provider: LlmProvider::OpenAiCompatible,
|
||||||
model: "interaction-test".to_string(),
|
model: "interaction-test".to_string(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("interaction-response".to_string()),
|
response_id: Some("interaction-response".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
-10
@@ -115,7 +115,6 @@ fn persist_tool_plan_handoff_repair_chain(
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -147,8 +146,6 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
|
|||||||
platform_llm::LlmStreamDelta {
|
platform_llm::LlmStreamDelta {
|
||||||
accumulated_text: accumulated_text.to_string(),
|
accumulated_text: accumulated_text.to_string(),
|
||||||
delta_text: delta_text.to_string(),
|
delta_text: delta_text.to_string(),
|
||||||
accumulated_reasoning: String::new(),
|
|
||||||
reasoning_delta: String::new(),
|
|
||||||
finish_reason: None,
|
finish_reason: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1006,7 +1003,6 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: old_llm.model.clone(),
|
model: old_llm.model.clone(),
|
||||||
text: private_response.to_string(),
|
text: private_response.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1120,7 +1116,6 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: old_llm.model.clone(),
|
model: old_llm.model.clone(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1307,7 +1302,6 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: format!("capacity response {loop_iteration}"),
|
text: format!("capacity response {loop_iteration}"),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1442,7 +1436,6 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1559,7 +1552,6 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: "cleanup handoff".to_string(),
|
text: "cleanup handoff".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1631,7 +1623,6 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: "terminal handoff".to_string(),
|
text: "terminal handoff".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1727,7 +1718,6 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: "已成功但尚未消费的回复".to_string(),
|
text: "已成功但尚未消费的回复".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -798,7 +798,6 @@ mod provider_reconciliation_diagnostic_tests {
|
|||||||
let response = platform_llm::LlmRunResponse {
|
let response = platform_llm::LlmRunResponse {
|
||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "test-model".to_string(),
|
model: "test-model".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
text: "C:\\private\\response".to_string(),
|
text: "C:\\private\\response".to_string(),
|
||||||
finish_reason: Some("completed".to_string()),
|
finish_reason: Some("completed".to_string()),
|
||||||
response_id: Some("response-1".to_string()),
|
response_id: Some("response-1".to_string()),
|
||||||
|
|||||||
@@ -905,7 +905,6 @@ mod tests {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "context-compaction-test".to_string(),
|
model: "context-compaction-test".to_string(),
|
||||||
text: summary.into(),
|
text: summary.into(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("context-compaction-response".to_string()),
|
response_id: Some("context-compaction-response".to_string()),
|
||||||
usage: Some(platform_llm::LlmTokenUsage {
|
usage: Some(platform_llm::LlmTokenUsage {
|
||||||
|
|||||||
@@ -14,16 +14,59 @@ const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30;
|
|||||||
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
||||||
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
||||||
|
|
||||||
|
/// 本进程内真正落盘持有项目写锁的线程登记表。
|
||||||
|
///
|
||||||
|
/// `.agent/project.lock` 的 `pid` 只能证明“锁由本进程的某条写通道持有”,它分不清
|
||||||
|
/// 两种完全不同的局面:
|
||||||
|
/// - **同一条调用链再次取锁**:持锁方就是自己,必须放行,否则每次嵌套项目写入都要
|
||||||
|
/// 白等一个等待预算再报“项目正在被其他写操作占用”;
|
||||||
|
/// - **本进程另一条写通道正在写**:项目 revision 侧车、steer 序号、一致快照读、
|
||||||
|
/// pending sidecar 复核和恢复安装都靠这把锁串行化,必须照旧等待。
|
||||||
|
///
|
||||||
|
/// 复用判据因此不能停在 `pid`:只有**当前线程**就是真实持锁线程时才返回 advisory
|
||||||
|
/// guard,本进程其余争用继续走有界等待与终态占用。登记按路径进行、按路径注销:
|
||||||
|
/// guard 可能被移到别的线程再 Drop(例如写入路径把锁交给阻塞线程池的持有者),
|
||||||
|
/// 按线程注销会漏项,让后续的重入判断失真。
|
||||||
|
static PROJECT_WRITE_LOCK_THREAD_OWNERS: std::sync::Mutex<Vec<(PathBuf, std::thread::ThreadId)>> =
|
||||||
|
std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
fn project_write_lock_thread_owners(
|
||||||
|
) -> std::sync::MutexGuard<'static, Vec<(PathBuf, std::thread::ThreadId)>> {
|
||||||
|
// 登记表只是复用判据的加速器:中毒时继续用内部值,不能让一次取锁失败升级成
|
||||||
|
// 整个进程再也写不了项目。
|
||||||
|
PROJECT_WRITE_LOCK_THREAD_OWNERS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_project_write_lock_thread_owner(path: &Path) {
|
||||||
|
let mut owners = project_write_lock_thread_owners();
|
||||||
|
if owners.iter().any(|(owner, _)| owner == path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
owners.push((path.to_path_buf(), std::thread::current().id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unregister_project_write_lock_thread_owner(path: &Path) {
|
||||||
|
project_write_lock_thread_owners().retain(|(owner, _)| owner != path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前线程是否就是这条锁路径上真实落盘的持有者(同线程重入)。
|
||||||
|
fn project_write_lock_reentered_by_current_thread(path: &Path) -> bool {
|
||||||
|
let thread = std::thread::current().id();
|
||||||
|
project_write_lock_thread_owners()
|
||||||
|
.iter()
|
||||||
|
.any(|(owner, owner_thread)| owner == path && *owner_thread == thread)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct ProjectWriteLock {
|
pub(crate) struct ProjectWriteLock {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
content: String,
|
content: String,
|
||||||
/// In the free-form autonomous lane a single Runtime process may have
|
/// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条
|
||||||
/// several specialist actions in flight at once. A file lock is still
|
/// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种
|
||||||
/// useful across processes, but making same-process contenders fail turns
|
/// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有
|
||||||
/// ordinary parallel work into a dead run (and can deadlock nested tool
|
/// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。
|
||||||
/// calls). Such a contender receives an in-process/advisory guard instead
|
|
||||||
/// of deleting the real holder's lock on drop.
|
|
||||||
bypassed_same_process: bool,
|
bypassed_same_process: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +90,7 @@ impl Drop for ProjectWriteLock {
|
|||||||
if self.bypassed_same_process {
|
if self.bypassed_same_process {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
unregister_project_write_lock_thread_owner(&self.path);
|
||||||
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
||||||
let _ = fs::remove_file(&self.path);
|
let _ = fs::remove_file(&self.path);
|
||||||
}
|
}
|
||||||
@@ -815,6 +859,7 @@ pub(crate) fn acquire_project_write_lock_failure(
|
|||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
register_project_write_lock_thread_owner(&path);
|
||||||
return Ok(ProjectWriteLock {
|
return Ok(ProjectWriteLock {
|
||||||
path,
|
path,
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
@@ -860,11 +905,15 @@ pub(crate) fn acquire_project_write_lock_failure(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if project_write_lock_is_owned_by_current_process(&path) {
|
if project_write_lock_is_owned_by_current_process(&path)
|
||||||
// A project lock is the client-use lock. Nested calls in
|
&& (crate::agent::autonomous_game_build_root_run_active_at(root)
|
||||||
// the same client process must reuse that ownership instead
|
|| project_write_lock_reentered_by_current_thread(&path))
|
||||||
// of waiting on their own durable marker. Cross-process
|
{
|
||||||
// contenders still take the normal retryable path.
|
// 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一
|
||||||
|
// 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory
|
||||||
|
// guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走
|
||||||
|
// 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装
|
||||||
|
// 的串行化。
|
||||||
return Ok(ProjectWriteLock {
|
return Ok(ProjectWriteLock {
|
||||||
path,
|
path,
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ impl AgentRuntimeProviderHandoffRecord {
|
|||||||
provider: self.response.provider,
|
provider: self.response.provider,
|
||||||
model: self.response.model.clone(),
|
model: self.response.model.clone(),
|
||||||
text: self.response.text.clone(),
|
text: self.response.text.clone(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: self.response.finish_reason.clone(),
|
finish_reason: self.response.finish_reason.clone(),
|
||||||
response_id: self.response.response_id.clone(),
|
response_id: self.response.response_id.clone(),
|
||||||
usage: self.response.usage.clone(),
|
usage: self.response.usage.clone(),
|
||||||
@@ -341,7 +340,6 @@ mod tests {
|
|||||||
provider: LlmProvider::OpenAiCompatible,
|
provider: LlmProvider::OpenAiCompatible,
|
||||||
model: "handoff-model".to_string(),
|
model: "handoff-model".to_string(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("response-handoff".to_string()),
|
response_id: Some("response-handoff".to_string()),
|
||||||
usage: Some(LlmTokenUsage {
|
usage: Some(LlmTokenUsage {
|
||||||
|
|||||||
@@ -2775,7 +2775,6 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "provider-handoff-runner-test".to_string(),
|
model: "provider-handoff-runner-test".to_string(),
|
||||||
text: "durable final reply".to_string(),
|
text: "durable final reply".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("provider-handoff-response".to_string()),
|
response_id: Some("provider-handoff-response".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -4475,7 +4475,6 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "real-e2e-checkpoint-model".to_string(),
|
model: "real-e2e-checkpoint-model".to_string(),
|
||||||
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
|
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("tool_calls".to_string()),
|
finish_reason: Some("tool_calls".to_string()),
|
||||||
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
|
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -4721,7 +4720,6 @@ fn agent_tool_plan_llm_response(
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "mock-game-model".to_string(),
|
model: "mock-game-model".to_string(),
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("tool_calls".to_string()),
|
finish_reason: Some("tool_calls".to_string()),
|
||||||
response_id: Some("response-tool-plan-test".to_string()),
|
response_id: Some("response-tool-plan-test".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -5818,8 +5818,27 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("allow direct file write");
|
.expect("allow direct file write");
|
||||||
let lock = acquire_project_write_lock(&root, "persistent-writer")
|
// 持锁方必须是**另一条线程**:本用例验证的是“别的写通道正在写时 file.write 必须
|
||||||
.expect("acquire persistent project writer");
|
// 走满等待预算并失败关闭”,同一条调用链自持锁属于重入复用,不会失败。
|
||||||
|
let holder_root = root.clone();
|
||||||
|
let (release_sender, release_receiver) = mpsc::channel::<()>();
|
||||||
|
let holder = std::thread::spawn(move || {
|
||||||
|
let lock = acquire_project_write_lock(&holder_root, "persistent-writer")
|
||||||
|
.expect("acquire persistent project writer");
|
||||||
|
let _ = release_receiver.recv();
|
||||||
|
drop(lock);
|
||||||
|
});
|
||||||
|
let lock_path = root.join(PROJECT_WRITE_LOCK_PATH);
|
||||||
|
for _ in 0..400 {
|
||||||
|
if lock_path.is_file() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
lock_path.is_file(),
|
||||||
|
"persistent writer must hold the project write lock"
|
||||||
|
);
|
||||||
|
|
||||||
let observation = execute_game_creator_agent_runtime_tool_action(
|
let observation = execute_game_creator_agent_runtime_tool_action(
|
||||||
&root,
|
&root,
|
||||||
@@ -5837,7 +5856,8 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
drop(lock);
|
let _ = release_sender.send(());
|
||||||
|
holder.join().expect("join persistent project writer");
|
||||||
assert_eq!(observation.status, "failed");
|
assert_eq!(observation.status, "failed");
|
||||||
assert!(!observation
|
assert!(!observation
|
||||||
.summary
|
.summary
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ impl AgentRuntimeToolPlanHandoffEntry {
|
|||||||
provider: self.response.provider,
|
provider: self.response.provider,
|
||||||
model: self.response.model.clone(),
|
model: self.response.model.clone(),
|
||||||
text,
|
text,
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: self.response.finish_reason.clone(),
|
finish_reason: self.response.finish_reason.clone(),
|
||||||
response_id: self.response.response_id.clone(),
|
response_id: self.response.response_id.clone(),
|
||||||
usage: self.response.usage.as_ref().map(LlmTokenUsage::from),
|
usage: self.response.usage.as_ref().map(LlmTokenUsage::from),
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ fn response(text: &str, tool_calls: Vec<LlmToolCall>) -> LlmRunResponse {
|
|||||||
provider: LlmProvider::OpenAiCompatible,
|
provider: LlmProvider::OpenAiCompatible,
|
||||||
model: "tool-plan-handoff-model".to_string(),
|
model: "tool-plan-handoff-model".to_string(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("tool_calls".to_string()),
|
finish_reason: Some("tool_calls".to_string()),
|
||||||
response_id: Some("tool-plan-handoff-response".to_string()),
|
response_id: Some("tool-plan-handoff-response".to_string()),
|
||||||
usage: Some(LlmTokenUsage {
|
usage: Some(LlmTokenUsage {
|
||||||
|
|||||||
@@ -1214,8 +1214,28 @@ mod tests {
|
|||||||
.expect("resolve primary");
|
.expect("resolve primary");
|
||||||
fs::write(&primary, b"{broken").expect("corrupt primary");
|
fs::write(&primary, b"{broken").expect("corrupt primary");
|
||||||
|
|
||||||
let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save")
|
// 持锁方必须是**另一条线程**:本用例验证的是“另一个写者持锁时恢复安装必须失败
|
||||||
.expect("hold project write lock");
|
// 关闭”,同一条调用链自持锁属于重入复用,不再产生占用失败。
|
||||||
|
let holder_root = directory.path().to_path_buf();
|
||||||
|
let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>();
|
||||||
|
let holder = std::thread::spawn(move || {
|
||||||
|
let lock = acquire_project_write_lock(&holder_root, "test.concurrent-save")
|
||||||
|
.expect("hold project write lock");
|
||||||
|
let _ = release_receiver.recv();
|
||||||
|
drop(lock);
|
||||||
|
});
|
||||||
|
let lock_path = resolve_local_project_path(directory.path(), PROJECT_WRITE_LOCK_PATH)
|
||||||
|
.expect("resolve project write lock path");
|
||||||
|
for _ in 0..400 {
|
||||||
|
if lock_path.is_file() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
lock_path.is_file(),
|
||||||
|
"concurrent writer must hold the project write lock"
|
||||||
|
);
|
||||||
let error = load_ui_design_state_at(LoadUiDesignStateInput {
|
let error = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||||
project_path: directory.path().to_string_lossy().into_owned(),
|
project_path: directory.path().to_string_lossy().into_owned(),
|
||||||
expected_project_id: PROJECT_ID.to_string(),
|
expected_project_id: PROJECT_ID.to_string(),
|
||||||
@@ -1224,7 +1244,8 @@ mod tests {
|
|||||||
.expect_err("recovery must not install while another writer holds the lock");
|
.expect_err("recovery must not install while another writer holds the lock");
|
||||||
assert!(error.contains("项目正在被其他写操作占用"));
|
assert!(error.contains("项目正在被其他写操作占用"));
|
||||||
assert!(read_ui_design_document_path(&primary).is_err());
|
assert!(read_ui_design_document_path(&primary).is_err());
|
||||||
drop(project_lock);
|
let _ = release_sender.send(());
|
||||||
|
holder.join().expect("join concurrent writer");
|
||||||
|
|
||||||
let recovered = load_ui_design_state_at(LoadUiDesignStateInput {
|
let recovered = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||||
project_path: directory.path().to_string_lossy().into_owned(),
|
project_path: directory.path().to_string_lossy().into_owned(),
|
||||||
|
|||||||
@@ -596,12 +596,6 @@ export function App({
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
clientTurnId: string;
|
clientTurnId: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const designAgentEventSubscriptionReadyRef = useRef<Promise<void> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
|
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
|
||||||
// Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。
|
// Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。
|
||||||
// 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。
|
// 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。
|
||||||
@@ -827,17 +821,7 @@ export function App({
|
|||||||
useState('');
|
useState('');
|
||||||
const planningV2TransientReplyTargetRef = useRef('');
|
const planningV2TransientReplyTargetRef = useRef('');
|
||||||
const planningV2VisibleReplyRef = useRef('');
|
const planningV2VisibleReplyRef = useRef('');
|
||||||
const designAgentPendingViewRef = useRef<{
|
|
||||||
clientTurnId: string;
|
|
||||||
projectPath: string;
|
|
||||||
view: DesignView;
|
|
||||||
} | null>(null);
|
|
||||||
const [planningV2Reasoning, setPlanningV2Reasoning] = useState('');
|
const [planningV2Reasoning, setPlanningV2Reasoning] = useState('');
|
||||||
const designAgentReasoningTurnRef = useRef<{
|
|
||||||
projectPath: string;
|
|
||||||
clientTurnId: string;
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
const planningV2TurnRef = useRef<{
|
const planningV2TurnRef = useRef<{
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
clientTurnId: string;
|
clientTurnId: string;
|
||||||
@@ -865,22 +849,6 @@ export function App({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function designAgentEventSubscriptionReady() {
|
|
||||||
if (!designAgentEventSubscriptionReadyRef.current) {
|
|
||||||
designAgentEventSubscriptionReadyRef.current = new Promise<void>(
|
|
||||||
(resolve) => {
|
|
||||||
designAgentEventSubscriptionResolveRef.current = resolve;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return designAgentEventSubscriptionReadyRef.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDesignAgentEventSubscriptionReady() {
|
|
||||||
designAgentEventSubscriptionResolveRef.current?.();
|
|
||||||
designAgentEventSubscriptionResolveRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
const target = planningV2TransientReplyTargetRef.current;
|
const target = planningV2TransientReplyTargetRef.current;
|
||||||
@@ -1037,66 +1005,6 @@ export function App({
|
|||||||
latestMessagesRef.current = conversation;
|
latestMessagesRef.current = conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitDesignAgentView(view: DesignView, projectPath: string) {
|
|
||||||
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
|
|
||||||
designAgentPendingViewRef.current = null;
|
|
||||||
applyDesignView(view, projectPath);
|
|
||||||
setPlanningV2TransientReplyTarget('');
|
|
||||||
if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) {
|
|
||||||
designAgentTurnRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDesignAgentViewAfterTransient(
|
|
||||||
view: DesignView,
|
|
||||||
projectPath: string,
|
|
||||||
clientTurnId: string,
|
|
||||||
) {
|
|
||||||
let target = planningV2TransientReplyTargetRef.current;
|
|
||||||
const tracked = designAgentTurnRef.current;
|
|
||||||
if (!target.trim() && !view.running) {
|
|
||||||
const latestAssistantText = [...view.messages]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => message.role !== 'user' && message.text.trim())
|
|
||||||
?.text.trim();
|
|
||||||
if (latestAssistantText) {
|
|
||||||
setPlanningV2TransientReplyTarget(latestAssistantText);
|
|
||||||
target = latestAssistantText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!view.running &&
|
|
||||||
tracked?.clientTurnId === clientTurnId &&
|
|
||||||
target.trim() &&
|
|
||||||
planningV2VisibleReplyRef.current !== target
|
|
||||||
) {
|
|
||||||
designAgentPendingViewRef.current = {
|
|
||||||
clientTurnId,
|
|
||||||
projectPath,
|
|
||||||
view,
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commitDesignAgentView(view, projectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
const pending = designAgentPendingViewRef.current;
|
|
||||||
if (!pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = planningV2TransientReplyTargetRef.current;
|
|
||||||
if (target && planningV2VisibleReplyRef.current !== target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commitDesignAgentView(pending.view, pending.projectPath);
|
|
||||||
}, 50);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
// 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function hydrateDesignAgentSession(nextProjectPath: string) {
|
async function hydrateDesignAgentSession(nextProjectPath: string) {
|
||||||
const invoke = resolveTauriInvoke();
|
const invoke = resolveTauriInvoke();
|
||||||
if (!invoke || !nextProjectPath.trim()) {
|
if (!invoke || !nextProjectPath.trim()) {
|
||||||
@@ -1130,17 +1038,9 @@ export function App({
|
|||||||
projectPath: nextProjectPath,
|
projectPath: nextProjectPath,
|
||||||
clientTurnId,
|
clientTurnId,
|
||||||
};
|
};
|
||||||
designAgentReasoningTurnRef.current = {
|
|
||||||
projectPath: nextProjectPath,
|
|
||||||
clientTurnId,
|
|
||||||
text: '',
|
|
||||||
};
|
|
||||||
designAgentPendingViewRef.current = null;
|
|
||||||
await designAgentEventSubscriptionReady();
|
|
||||||
setChatAgentBusy(true);
|
setChatAgentBusy(true);
|
||||||
setProjectSupervisorRuntimeError('');
|
setProjectSupervisorRuntimeError('');
|
||||||
setPlanningV2TransientReplyTarget('');
|
setPlanningV2TransientReplyTarget('');
|
||||||
setPlanningV2Reasoning('');
|
|
||||||
try {
|
try {
|
||||||
const view = await invoke<DesignView>('continue_design_agent_session', {
|
const view = await invoke<DesignView>('continue_design_agent_session', {
|
||||||
projectPath: nextProjectPath,
|
projectPath: nextProjectPath,
|
||||||
@@ -1150,7 +1050,7 @@ export function App({
|
|||||||
if (localProjectPathRef.current !== nextProjectPath) {
|
if (localProjectPathRef.current !== nextProjectPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
|
applyDesignView(view, nextProjectPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (localProjectPathRef.current !== nextProjectPath) {
|
if (localProjectPathRef.current !== nextProjectPath) {
|
||||||
return;
|
return;
|
||||||
@@ -1162,10 +1062,8 @@ export function App({
|
|||||||
setProjectSupervisorRuntimeError(message);
|
setProjectSupervisorRuntimeError(message);
|
||||||
setPlanGddError(message);
|
setPlanGddError(message);
|
||||||
} finally {
|
} finally {
|
||||||
if (!designAgentPendingViewRef.current) {
|
designAgentTurnRef.current = null;
|
||||||
designAgentTurnRef.current = null;
|
setPlanningV2TransientReplyTarget('');
|
||||||
setPlanningV2TransientReplyTarget('');
|
|
||||||
}
|
|
||||||
setChatAgentBusy(false);
|
setChatAgentBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1657,9 +1555,6 @@ export function App({
|
|||||||
setProjectSupervisorRuntimeError('');
|
setProjectSupervisorRuntimeError('');
|
||||||
setPlanningV2Session(null);
|
setPlanningV2Session(null);
|
||||||
setPlanningV2TransientReplyTarget('');
|
setPlanningV2TransientReplyTarget('');
|
||||||
designAgentPendingViewRef.current = null;
|
|
||||||
designAgentReasoningTurnRef.current = null;
|
|
||||||
setPlanningV2Reasoning('');
|
|
||||||
setPlanningV2Active(planningStartMode);
|
setPlanningV2Active(planningStartMode);
|
||||||
planningV2ActiveRef.current = planningStartMode;
|
planningV2ActiveRef.current = planningStartMode;
|
||||||
designAgentLaneRef.current = planningStartMode;
|
designAgentLaneRef.current = planningStartMode;
|
||||||
@@ -2095,14 +1990,8 @@ export function App({
|
|||||||
}, [planningV2Active]);
|
}, [planningV2Active]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ready = designAgentEventSubscriptionReady();
|
|
||||||
if (!canSubscribeTauriEvents() || !planningV2Active) {
|
if (!canSubscribeTauriEvents() || !planningV2Active) {
|
||||||
resolveDesignAgentEventSubscriptionReady();
|
return;
|
||||||
return () => {
|
|
||||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
|
||||||
designAgentEventSubscriptionReadyRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
let cleanup: (() => void) | null = null;
|
let cleanup: (() => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -2119,45 +2008,26 @@ export function App({
|
|||||||
setPlanningV2TransientReplyTarget(payload.text);
|
setPlanningV2TransientReplyTarget(payload.text);
|
||||||
}
|
}
|
||||||
if (payload.reasoningText != null) {
|
if (payload.reasoningText != null) {
|
||||||
const reasoningTurn = designAgentReasoningTurnRef.current;
|
setPlanningV2Reasoning(payload.reasoningText);
|
||||||
if (
|
|
||||||
reasoningTurn &&
|
|
||||||
reasoningTurn.projectPath === payload.projectPath &&
|
|
||||||
reasoningTurn.clientTurnId === payload.clientTurnId
|
|
||||||
) {
|
|
||||||
reasoningTurn.text = payload.reasoningText;
|
|
||||||
setPlanningV2Reasoning(payload.reasoningText);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (payload.kind === 'tool' && payload.text) {
|
if (payload.kind === 'tool' && payload.text) {
|
||||||
setPlanningV2TransientReplyTarget(payload.text);
|
setPlanningV2TransientReplyTarget(payload.text);
|
||||||
}
|
}
|
||||||
if (payload.view) {
|
if (payload.view) {
|
||||||
applyDesignAgentViewAfterTransient(
|
applyDesignView(payload.view, payload.projectPath);
|
||||||
payload.view,
|
|
||||||
payload.projectPath,
|
|
||||||
payload.clientTurnId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((unlisten) => {
|
.then((unlisten) => {
|
||||||
resolveDesignAgentEventSubscriptionReady();
|
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
unlisten();
|
unlisten();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cleanup = unlisten;
|
cleanup = unlisten;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => undefined);
|
||||||
resolveDesignAgentEventSubscriptionReady();
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
resolveDesignAgentEventSubscriptionReady();
|
|
||||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
|
||||||
designAgentEventSubscriptionReadyRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// applyDesignView 读的是 refs 和当前项目路径,
|
// applyDesignView 读的是 refs 和当前项目路径,
|
||||||
// 把它写进依赖会在每轮回复时重订事件。
|
// 把它写进依赖会在每轮回复时重订事件。
|
||||||
@@ -6202,7 +6072,6 @@ export function App({
|
|||||||
setChatAgentBusy(true);
|
setChatAgentBusy(true);
|
||||||
setProjectSupervisorRuntimeError('');
|
setProjectSupervisorRuntimeError('');
|
||||||
setPlanningV2TransientReplyTarget('');
|
setPlanningV2TransientReplyTarget('');
|
||||||
setPlanningV2Reasoning('');
|
|
||||||
try {
|
try {
|
||||||
const result = currentSessionId
|
const result = currentSessionId
|
||||||
? await invoke<PlanningSessionCommandResultV2>(
|
? await invoke<PlanningSessionCommandResultV2>(
|
||||||
@@ -11955,25 +11824,15 @@ export function App({
|
|||||||
projectPath: nextProjectPath,
|
projectPath: nextProjectPath,
|
||||||
clientTurnId,
|
clientTurnId,
|
||||||
};
|
};
|
||||||
designAgentReasoningTurnRef.current = {
|
|
||||||
projectPath: nextProjectPath,
|
|
||||||
clientTurnId,
|
|
||||||
text: '',
|
|
||||||
};
|
|
||||||
designAgentPendingViewRef.current = null;
|
|
||||||
setPlanningV2TransientReplyTarget('');
|
setPlanningV2TransientReplyTarget('');
|
||||||
setPlanningV2Reasoning('');
|
|
||||||
setChatAgentBusy(true);
|
setChatAgentBusy(true);
|
||||||
setPlanGddDecisionBusy(true);
|
setPlanGddDecisionBusy(true);
|
||||||
void designAgentEventSubscriptionReady()
|
void invoke<DesignView>('decide_design_phase', {
|
||||||
.then(() =>
|
projectPath: nextProjectPath,
|
||||||
invoke<DesignView>('decide_design_phase', {
|
clientTurnId,
|
||||||
projectPath: nextProjectPath,
|
requestId,
|
||||||
clientTurnId,
|
approved,
|
||||||
requestId,
|
})
|
||||||
approved,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((view) => {
|
.then((view) => {
|
||||||
if (
|
if (
|
||||||
localProjectPathRef.current !== nextProjectPath ||
|
localProjectPathRef.current !== nextProjectPath ||
|
||||||
@@ -11983,11 +11842,7 @@ export function App({
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyDesignAgentViewAfterTransient(
|
applyDesignView(view, nextProjectPath);
|
||||||
view,
|
|
||||||
nextProjectPath,
|
|
||||||
clientTurnId,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (
|
if (
|
||||||
@@ -12009,10 +11864,8 @@ export function App({
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!designAgentPendingViewRef.current) {
|
designAgentTurnRef.current = null;
|
||||||
designAgentTurnRef.current = null;
|
setPlanningV2TransientReplyTarget('');
|
||||||
setPlanningV2TransientReplyTarget('');
|
|
||||||
}
|
|
||||||
setChatAgentBusy(false);
|
setChatAgentBusy(false);
|
||||||
setPlanGddDecisionBusy(false);
|
setPlanGddDecisionBusy(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -263,11 +263,8 @@ export function ProjectSupervisorView({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{designReasoning ? (
|
{designReasoning ? (
|
||||||
<details
|
<details className="design-agent-reasoning">
|
||||||
className="design-agent-reasoning"
|
<summary>显示思考过程</summary>
|
||||||
aria-label="策划 Agent 思考过程"
|
|
||||||
>
|
|
||||||
<summary>思考过程(点击展开)</summary>
|
|
||||||
<pre>{designReasoning}</pre>
|
<pre>{designReasoning}</pre>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
React,
|
React,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
setComposerText,
|
|
||||||
waitFor,
|
waitFor,
|
||||||
} from './harness';
|
} from './harness';
|
||||||
|
|
||||||
@@ -56,24 +55,6 @@ function designClarificationView() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function designConversationView() {
|
|
||||||
return {
|
|
||||||
session: {
|
|
||||||
sessionId: 'design-session-reasoning',
|
|
||||||
projectId: 'local-project-draft',
|
|
||||||
currentPhase: 'concept',
|
|
||||||
approvedPhases: [],
|
|
||||||
pendingApproval: null,
|
|
||||||
pendingClarification: null,
|
|
||||||
turnIndex: 1,
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
messages: [],
|
|
||||||
running: false,
|
|
||||||
canRetry: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerDesignAgentSurfaceTests() {
|
export function registerDesignAgentSurfaceTests() {
|
||||||
it('hydrates an existing design session and decides approval through design commands', async () => {
|
it('hydrates an existing design session and decides approval through design commands', async () => {
|
||||||
const harness = createProjectSupervisorRuntimeHarness({
|
const harness = createProjectSupervisorRuntimeHarness({
|
||||||
@@ -160,54 +141,4 @@ export function registerDesignAgentSurfaceTests() {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the current turn reasoning after completion and supports collapse/expand', async () => {
|
|
||||||
const harness = createProjectSupervisorRuntimeHarness({
|
|
||||||
designAgentView: designConversationView(),
|
|
||||||
designAgentContinueView: designConversationView(),
|
|
||||||
});
|
|
||||||
window.__TAURI__ = {
|
|
||||||
core: { invoke: harness.invoke },
|
|
||||||
event: { listen: harness.listen },
|
|
||||||
};
|
|
||||||
window.history.pushState({}, '', '/');
|
|
||||||
render(
|
|
||||||
React.createElement(App, {
|
|
||||||
initialProjectPath: harness.projectPath,
|
|
||||||
orchestrationMode: 'single-supervisor',
|
|
||||||
planningStartMode: true,
|
|
||||||
projectSupervisorOnly: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = await screen.findByLabelText('项目需求');
|
|
||||||
await setComposerText(input, '请给出核心玩法方案');
|
|
||||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
harness.invoke.mock.calls.some(
|
|
||||||
([command]) => command === 'continue_design_agent_session',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
const continueCall = [...harness.invoke.mock.calls]
|
|
||||||
.reverse()
|
|
||||||
.find(([command]) => command === 'continue_design_agent_session');
|
|
||||||
const clientTurnId = String(
|
|
||||||
(continueCall?.[1] as { clientTurnId?: string }).clientTurnId,
|
|
||||||
);
|
|
||||||
harness.emitDesignAgentEvent({
|
|
||||||
projectPath: harness.projectPath,
|
|
||||||
clientTurnId,
|
|
||||||
kind: 'reasoning',
|
|
||||||
reasoningText: '先分析需求,再组织方案。',
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = await screen.findByText('思考过程(点击展开)');
|
|
||||||
const details = summary.closest('details') as HTMLDetailsElement;
|
|
||||||
expect(details.open).toBe(false);
|
|
||||||
fireEvent.click(summary);
|
|
||||||
expect(details.open).toBe(true);
|
|
||||||
expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,9 +741,6 @@ function createProjectSupervisorRuntimeHarness({
|
|||||||
};
|
};
|
||||||
}) => void)
|
}) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
let designAgentUpdateHandler:
|
|
||||||
| ((event: { payload: Record<string, unknown> }) => void)
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
const conversationRecord = (
|
const conversationRecord = (
|
||||||
role: 'user' | 'assistant',
|
role: 'user' | 'assistant',
|
||||||
@@ -1116,10 +1113,6 @@ function createProjectSupervisorRuntimeHarness({
|
|||||||
if (eventName === 'game-creator-agent-progress') {
|
if (eventName === 'game-creator-agent-progress') {
|
||||||
progressHandler = handler as unknown as typeof progressHandler;
|
progressHandler = handler as unknown as typeof progressHandler;
|
||||||
}
|
}
|
||||||
if (eventName === 'design-agent-update') {
|
|
||||||
designAgentUpdateHandler =
|
|
||||||
handler as unknown as typeof designAgentUpdateHandler;
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
if (runtimeUpdateHandler === handler) {
|
if (runtimeUpdateHandler === handler) {
|
||||||
runtimeUpdateHandler = null;
|
runtimeUpdateHandler = null;
|
||||||
@@ -1130,9 +1123,6 @@ function createProjectSupervisorRuntimeHarness({
|
|||||||
if (progressHandler === handler) {
|
if (progressHandler === handler) {
|
||||||
progressHandler = null;
|
progressHandler = null;
|
||||||
}
|
}
|
||||||
if (designAgentUpdateHandler === handler) {
|
|
||||||
designAgentUpdateHandler = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1159,9 +1149,6 @@ function createProjectSupervisorRuntimeHarness({
|
|||||||
setPlanningV2Result(state: Record<string, unknown> | null) {
|
setPlanningV2Result(state: Record<string, unknown> | null) {
|
||||||
currentPlanningV2Result = state;
|
currentPlanningV2Result = state;
|
||||||
},
|
},
|
||||||
emitDesignAgentEvent(payload: Record<string, unknown>) {
|
|
||||||
designAgentUpdateHandler?.({ payload });
|
|
||||||
},
|
|
||||||
setPlanningV2StartResult(state: Record<string, unknown> | null) {
|
setPlanningV2StartResult(state: Record<string, unknown> | null) {
|
||||||
currentPlanningV2StartResult = state;
|
currentPlanningV2StartResult = state;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md`
|
|||||||
## 修改顺序
|
## 修改顺序
|
||||||
|
|
||||||
1. 统一同进程嵌套调用的项目锁语义,禁止自等待。
|
1. 统一同进程嵌套调用的项目锁语义,禁止自等待。
|
||||||
2. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。
|
2. 收窄复用判据:按 `pid` 放行会放过本进程其它线程的并行写,改为按“当前线程就是真实持锁线程”判定重入,并保住同进程跨线程的等待与终态占用。
|
||||||
3. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。
|
3. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。
|
||||||
4. 补齐同进程重入、跨进程占用、崩溃恢复和锁释放测试。
|
4. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。
|
||||||
|
5. 补齐同进程重入、同进程跨线程争用、跨进程占用、崩溃恢复和锁释放测试。
|
||||||
|
|
||||||
## 验证命令
|
## 验证命令
|
||||||
|
|
||||||
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
|
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
|
||||||
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock_reuses_same_process_owner_and_releases_on_drop --no-default-features`
|
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`
|
||||||
|
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock --no-default-features`
|
||||||
- Runner owner 与 response stream 相关定向测试
|
- Runner owner 与 response stream 相关定向测试
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
@@ -31,4 +33,5 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md`
|
|||||||
|
|
||||||
- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。
|
- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。
|
||||||
- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。
|
- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。
|
||||||
|
- 复用判据按线程判定:出现同进程跨线程重入的现场时先按 `*_locked` 入口处置,不要把判据退回按 `pid` 一律放行(那会放过并行写,见里程碑「边界」末条)。
|
||||||
- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。
|
- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
# 【里程碑】Provider 推理与正文分离及策划 Agent 展示
|
|
||||||
|
|
||||||
| 字段 | 值 |
|
|
||||||
| --- | --- |
|
|
||||||
| Version | 1.0 |
|
|
||||||
| Status | in-progress |
|
|
||||||
| Date | 2026-09-14 |
|
|
||||||
| Parent Spec | `docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md` |
|
|
||||||
| Related Issue | `GenarrativeAI/Genarrative#331` |
|
|
||||||
|
|
||||||
## 一句话交付结果
|
|
||||||
|
|
||||||
让策划 Agent 能在流式回合中单独收到 Provider reasoning,并在 UI 中以默认折叠的思考过程展示;用户可见正文、工具调用和 GameAgent 现有行为保持不变。
|
|
||||||
|
|
||||||
## 当前实现进度(2026-09-14)
|
|
||||||
|
|
||||||
- 已完成共享 reasoning 字段、Provider 解析、策划事件映射以及正文流式收尾的前两轮提交。
|
|
||||||
- 当前第三轮聚焦策划 Agent 前端 reasoning 生命周期:按 `projectPath + clientTurnId` 绑定事件,回合结束后保留本轮 reasoning,下一轮或项目切换时清理。
|
|
||||||
- 现有 `<details>` 展示保持默认收起,并提供明确的展开/收起入口;不新增持久化字段,也不改动 GameAgent、Direct/Codex、supervisor 或已退役链路。
|
|
||||||
|
|
||||||
## 背景与现状
|
|
||||||
|
|
||||||
- `platform-llm` 当前只向上层提供正文累计值、正文增量和结束状态。
|
|
||||||
- Chat 兼容响应中的 `reasoning`、`reasoning_content` 以及 reasoning content part 会被正文提取器过滤。
|
|
||||||
- Responses 响应中的 reasoning 类型 output item 也不会进入独立的上层字段。
|
|
||||||
- 策划 Agent 已经预留 `DesignEvent.reasoningText`、`planningV2Reasoning` 和默认折叠 UI,但 Provider 解析链没有产出数据,因此折叠区通常不出现。
|
|
||||||
- GameAgent 当前只消费 `delta_text`、`accumulated_text` 和 `finish_reason`,没有消费策划 Agent 的 `reasoningText`。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
1. 为 Provider 流式响应增加独立 reasoning 增量和累计通道。
|
|
||||||
2. 为非流式终态响应提供独立 reasoning 字段。
|
|
||||||
3. 支持 Responses 和 Chat 兼容协议的 reasoning 解析。
|
|
||||||
4. 仅由策划 Agent 显式启用 reasoning 捕获和 UI 转发。
|
|
||||||
5. 保证 reasoning 不进入用户可见正文、工具调用参数或 GameAgent 消息流。
|
|
||||||
6. 在无 reasoning、reasoning 解析异常、重试和工具调用共存场景下保持可恢复行为。
|
|
||||||
|
|
||||||
## 非目标
|
|
||||||
|
|
||||||
- 不改变 GameAgent 的正文展示、工具调用、`<think>` 过滤和运行时状态语义。
|
|
||||||
- 不把 reasoning 自动拼接到 `delta_text`、`accumulated_text` 或正式 assistant 消息。
|
|
||||||
- 不把 reasoning 作为新的业务消息类型写入策划会话历史。
|
|
||||||
- 不新增通用 reasoning UI,不改造 Direct/Codex 的过程卡展示。
|
|
||||||
- 不修改 Provider 请求模型、推理档位或 token 预算。
|
|
||||||
- 不为 reasoning 增加新的 SpacetimeDB 表、公开 API 或持久化 schema。
|
|
||||||
|
|
||||||
## 受影响模块与边界
|
|
||||||
|
|
||||||
### Provider 共享层
|
|
||||||
|
|
||||||
`server-rs/crates/platform-llm` 负责协议解析和流式累计:
|
|
||||||
|
|
||||||
- `LlmStreamDelta` 增加 `reasoning_delta` 与 `accumulated_reasoning`。
|
|
||||||
- `LlmRunResponse` 增加终态 reasoning 字段。
|
|
||||||
- `LlmRunRequest` 增加默认关闭的 reasoning 捕获开关。
|
|
||||||
- 正文提取继续排除隐藏 reasoning part;reasoning 进入旁路字段。
|
|
||||||
- reasoning 解析失败只丢弃 reasoning,不影响正文和工具调用。
|
|
||||||
|
|
||||||
### 策划 Runtime
|
|
||||||
|
|
||||||
`apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs` 仅在策划专用请求中打开 reasoning 捕获:
|
|
||||||
|
|
||||||
- 流式 reasoning 更新映射到已有 `DesignEvent.reasoningText`。
|
|
||||||
- 正文继续使用已有 `text` 事件。
|
|
||||||
- 新回合、重试、项目切换和请求失败时清理旧 reasoning。
|
|
||||||
- debug 记录与正文记录分开,内容受现有 debug 开关和长度限制约束。
|
|
||||||
|
|
||||||
### 其它调用方
|
|
||||||
|
|
||||||
GameAgent、Agent Interaction、Direct/Codex 适配层和通用 runtime 继续只读取正文字段。新增 reasoning 字段默认为空,不改变这些调用方的业务判断。
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
|
|
||||||
复用现有 `ProjectSupervisorView` 的 `designReasoning` 和默认折叠 `<details>` 展示。只补事件生命周期和状态清理,不新建平行组件或平行状态协议。
|
|
||||||
|
|
||||||
## 分步实施方案
|
|
||||||
|
|
||||||
### 第一步:冻结共享契约与兼容开关
|
|
||||||
|
|
||||||
明确字段语义、空值语义和捕获开关:
|
|
||||||
|
|
||||||
- reasoning 字段只表示 Provider 返回的内部推理内容,不代表用户正文。
|
|
||||||
- 捕获开关默认关闭;未启用时新增字段为空。
|
|
||||||
- 正文、工具调用、finish reason 和 Responses 原生 output 的现有语义保持不变。
|
|
||||||
- 该步只更新规范、类型定义和构造点,不接入策划 UI。
|
|
||||||
|
|
||||||
验收重点:所有现有 Rust 构造点可编译,GameAgent 现有调用仍只依赖正文字段。
|
|
||||||
|
|
||||||
### 第二步:实现 `platform-llm` 协议解析
|
|
||||||
|
|
||||||
分别补齐:
|
|
||||||
|
|
||||||
- Responses reasoning 增量事件;
|
|
||||||
- Responses 终态 reasoning output item / summary;
|
|
||||||
- Chat `reasoning`、`reasoning_content` 和 reasoning content part;
|
|
||||||
- 正文与 reasoning 的独立累计;
|
|
||||||
- reasoning 与正文、工具调用同时出现时的顺序和去重;
|
|
||||||
- reasoning 解析失败时的降级行为。
|
|
||||||
|
|
||||||
Responses 的原生 output 仍按当前方式保留,用于后续 Responses 会话回放;新增 reasoning 字段只用于上层展示和调试消费。
|
|
||||||
|
|
||||||
验收重点:正文永远不含 reasoning;无 reasoning 的响应与当前行为一致。
|
|
||||||
|
|
||||||
### 第三步:补齐共享适配层并锁定 GameAgent 不变
|
|
||||||
|
|
||||||
更新 `LlmStreamDelta` 构造点、适配器和测试辅助函数,使它们为新增字段提供空值。检查并锁定:
|
|
||||||
|
|
||||||
- GameAgent 正文流不读取 reasoning;
|
|
||||||
- 工具调用判断不读取 reasoning;
|
|
||||||
- Direct/Codex 过程卡不显示 reasoning;
|
|
||||||
- 通用 response stream 过滤逻辑不因新增字段改变。
|
|
||||||
|
|
||||||
验收重点:现有工具调用、正文流式、Direct 和 Agent Interaction 测试无行为回归。
|
|
||||||
|
|
||||||
### 第四步:接通策划 Runtime 与现有 UI
|
|
||||||
|
|
||||||
仅在策划 Agent Provider 请求中启用捕获开关:
|
|
||||||
|
|
||||||
- 收到 reasoning 增量时发出独立 `reasoningText`;
|
|
||||||
- 收到正文增量时继续发出原有 `text`;
|
|
||||||
- 重试时替换同一回合的临时 reasoning,不残留上一 attempt;
|
|
||||||
- 正式回合结束后保留本回合展示,下一回合开始时清理;
|
|
||||||
- UI 默认折叠,展开后显示累计 reasoning,不影响正文滚动和输入。
|
|
||||||
|
|
||||||
验收重点:策划 Agent 能看到独立 reasoning,正文气泡不重复、不混入推理文本。
|
|
||||||
|
|
||||||
### 第五步:完成回归、文档和验收证据
|
|
||||||
|
|
||||||
形成逐条证据矩阵,至少覆盖:
|
|
||||||
|
|
||||||
- Responses reasoning 增量和终态;
|
|
||||||
- Chat reasoning 字段和 content part;
|
|
||||||
- 正文与 reasoning 分离;
|
|
||||||
- reasoning 与工具调用并存;
|
|
||||||
- reasoning 解析失败降级;
|
|
||||||
- 无 reasoning 兼容行为;
|
|
||||||
- 策划 Runtime 事件映射和 UI 生命周期;
|
|
||||||
- GameAgent 正文与工具调用回归。
|
|
||||||
|
|
||||||
## 第五轮验收证据
|
|
||||||
|
|
||||||
| 验收面 | 证据 | 结果 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Chat / Responses reasoning 解析 | `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` | PASS,152 个单元测试;含字段、content part、SSE 增量、终态快照和正文隔离 |
|
|
||||||
| reasoning 与工具调用共存 | `responses_response_captures_reasoning_alongside_tool_call`、既有 Chat/Responses 流式工具测试 | PASS |
|
|
||||||
| 默认关闭与请求兼容 | `run_request_defaults_to_openai_responses_api_kind`、`reasoning_capture_switch_does_not_change_provider_request_body` | PASS |
|
|
||||||
| 策划 Runtime 生命周期 | `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell design_runtime` | PASS,10 个测试;含事件映射、history 隔离、重试清理和失败清理 |
|
|
||||||
| GameAgent / Direct/Codex 正文回归 | `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests` 与现有 response stream / direct tests 编译 | PASS;新增字段未进入正文消费路径 |
|
|
||||||
| 前端与文档门禁 | `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` | PASS |
|
|
||||||
| 格式门禁 | `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`、AGC Tauri 同命令 | PASS |
|
|
||||||
|
|
||||||
真实 Provider、浏览器运行时 smoke 和 `check-config.mjs` 的 Windows 私有 DACL 路径本轮未验证;前者需要凭据和运行环境,后者受当前沙箱权限限制,不能据此扩大验收结论。
|
|
||||||
|
|
||||||
## 契约与持久化策略
|
|
||||||
|
|
||||||
- 不修改 HTTP API、OpenAPI、SpacetimeDB schema 或生成绑定。
|
|
||||||
- 不新增正式持久化字段;策划会话仍保存既有对话和 Responses 原生 output。
|
|
||||||
- reasoning 捕获开关属于 Provider 请求的内部调用语义,默认关闭,不改变已有请求的默认指纹和展示行为。
|
|
||||||
- reasoning 不作为下一轮普通用户可见正文回灌;Responses 原生 output 的恢复语义保持现状。
|
|
||||||
|
|
||||||
## 失败、重试与恢复
|
|
||||||
|
|
||||||
- reasoning 解析失败:保留正文和工具调用,reasoning 字段置空或保留已累计部分。
|
|
||||||
- Provider 瞬态重试:reasoning 与正文使用同一回合、同一响应槽,新的 attempt 替换临时值。
|
|
||||||
- 流中断:沿用现有 Provider 错误和策划会话恢复规则,不把未完成 reasoning 误判为正式消息。
|
|
||||||
- UI 刷新或项目恢复:只从当前事件/状态恢复 reasoning,不隐式唤醒 Provider。
|
|
||||||
|
|
||||||
## 风险与回滚点
|
|
||||||
|
|
||||||
| 风险 | 控制措施 | 回滚点 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 共享结构体新增字段导致构造点遗漏 | 先补齐所有构造点和编译检查 | 回退共享字段提交 |
|
|
||||||
| Provider 把 reasoning 混入正文 | 保留独立提取器和正文过滤测试 | 关闭 reasoning 捕获开关 |
|
|
||||||
| Responses summary 事件重复累计 | 以增量事件为主,终态仅做快照/兜底 | 关闭对应事件解析 |
|
|
||||||
| GameAgent 意外展示 reasoning | 捕获默认关闭,调用方只读正文字段 | 回退策划开关,不影响共享解析 |
|
|
||||||
| 重试残留旧 reasoning | 按回合和响应槽清理/替换 | 回退 UI 事件消费 |
|
|
||||||
|
|
||||||
## 验收命令
|
|
||||||
|
|
||||||
代码实现阶段按里程碑执行,不在本计划阶段运行业务测试。预计命令:
|
|
||||||
|
|
||||||
```text
|
|
||||||
cargo test -p platform-llm
|
|
||||||
cargo test -p ai-game-creator-shell
|
|
||||||
npm run typecheck
|
|
||||||
npm run check:encoding
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
文档阶段已要求补充运行:
|
|
||||||
|
|
||||||
```text
|
|
||||||
npm run check:doc-index
|
|
||||||
npm run check:encoding
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
## 当前状态与下一步
|
|
||||||
|
|
||||||
当前仅完成问题定位和方案设计,未修改业务代码。进入实现前应先评审本里程碑的字段语义、默认关闭策略、Responses 事件覆盖范围和 reasoning 是否进入 debug 记录;评审通过后再为单个里程碑建立对应的 `【实施计划】` 文档。
|
|
||||||
@@ -7,22 +7,24 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施
|
|||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内的嵌套调用复用既有项目锁,不因自身持锁进入等待。
|
项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内**同一条写调用链(同一线程)的嵌套调用**复用既有项目锁,不因自身持锁进入等待。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。
|
- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。
|
||||||
- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。
|
- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。
|
||||||
- 不改变项目 revision、权限、幂等、恢复和数据格式合同。
|
- 不改变项目 revision、权限、幂等、恢复和数据格式合同。**本进程其它线程的并发写入必须继续串行化**:按 `pid` 一律返回 advisory guard 会放过并行写,直接违反本边界(见验收标准第 2 条)。
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 同一进程内嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。
|
- 同一线程(同一条写调用链)嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。
|
||||||
|
- 本进程另一条线程持锁(模拟“另一个写通道/另一个客户端”的既有用例形态)时仍保持等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装不得被复用判据放过。
|
||||||
- 不同进程持有项目锁时仍保持占用失败与残留回收判据。
|
- 不同进程持有项目锁时仍保持占用失败与残留回收判据。
|
||||||
- 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。
|
- 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。
|
||||||
- 锁释放后下一客户端可重新取得锁。
|
- 锁释放后下一客户端可重新取得锁。
|
||||||
- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。
|
- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过;锁语义变更必须跑 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` 全量,定向用例覆盖不到 `project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence` 里的锁不变量。
|
||||||
|
|
||||||
## 未决事项
|
## 未决事项
|
||||||
|
|
||||||
- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。
|
- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。
|
||||||
|
- 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会走有界等待,预算耗尽时报“项目正在被其他写操作占用”。发现这类现场时按 2026-08-27 的既有处置改用 `*_locked` 入口复用已有 guard(`project-memory/shared-memory/pitfalls.md`「持锁调用链二次取锁」),不放宽整条锁的串行化语义。
|
||||||
|
|||||||
@@ -8637,3 +8637,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
|||||||
- 决策:DirectProject app-server thread 改为 `sandbox="danger-full-access"`,turn 改为 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `writableRoots` 或 workspace 网络开关,原生命令网络随完整 sandbox 开放;app-server 交互请求不再按 grant root 做白名单裁剪,直接项目会话统一接受文件变更、命令执行和权限请求。首页只读对话、AGC `agc_tools` 业务授权、Provider 凭据隔离、Runtime 审计和客户端受控文件工具合同继续保留。
|
- 决策:DirectProject app-server thread 改为 `sandbox="danger-full-access"`,turn 改为 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `writableRoots` 或 workspace 网络开关,原生命令网络随完整 sandbox 开放;app-server 交互请求不再按 grant root 做白名单裁剪,直接项目会话统一接受文件变更、命令执行和权限请求。首页只读对话、AGC `agc_tools` 业务授权、Provider 凭据隔离、Runtime 审计和客户端受控文件工具合同继续保留。
|
||||||
- 提示词同步:DirectProject 不再把路径范围描述成 Codex 原生能力禁区,但仍禁止主动输出 Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面。
|
- 提示词同步:DirectProject 不再把路径范围描述成 Codex 原生能力禁区,但仍禁止主动输出 Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面。
|
||||||
- 验证:Rust 定向单测覆盖 `danger-full-access` / `dangerFullAccess`、无 `writableRoots`、外部 grant root 仍接受,以及 DirectHome 继续只读拒绝。
|
- 验证:Rust 定向单测覆盖 `danger-full-access` / `dangerFullAccess`、无 `writableRoots`、外部 grant root 仍接受,以及 DirectHome 继续只读拒绝。
|
||||||
|
|
||||||
|
## 2026-09-14 项目写锁的同进程复用收窄为同线程重入
|
||||||
|
|
||||||
|
- 背景:`write_lock.rs` 的 advisory 复用判据曾放宽为「`.agent/project.lock` 的 `pid` 等于当前进程」,使本进程所有写通道都不再等待。`Project CI` 的 Rust 全量门禁因此出现 12 条失败:另一线程持锁时一致快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待,4 路并行直写撞项目 revision 侧车(`File exists (os error 17)`),8 线程并发 steer 拿到重复序号,`file.write` 锁失败脱敏与恢复安装的失败关闭变成成功。
|
||||||
|
- 决策:复用判据收窄为**同一条写调用链(同一线程)重入**——按锁路径登记真实持锁线程,只有当前线程就是持锁线程时才返回 advisory guard;本进程其它线程的争用继续走有界等待与终态占用。自主游戏构建流水线的并行专家动作豁免保持不变;跨进程占用、残留回收、权限分类、等待预算和错误文案不变。
|
||||||
|
- 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。
|
||||||
|
- 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 踩坑与排障记录
|
# 踩坑与排障记录
|
||||||
|
|
||||||
|
## 2026-09-14 项目写锁的同进程复用判据不能只看 pid
|
||||||
|
|
||||||
|
- **现象**:`master` 的 `Project CI / Native shell tests` 红在 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`,12 条用例失败(`2439 passed; 12 failed`)。断言分三类:① 另一线程持锁时快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待(`... must wait for the project consistency lock`);② 并发写不再串行化——4 路并行直写撞项目 revision 侧车报 `File exists (os error 17)`,8 线程并发 steer 拿到 `[1, 1, 1, 1, 1, 1, 1, 2]`;③ 别的写通道持锁时 `file.write` 与恢复安装必须失败关闭,实测变成 `ok` / 不再报占用。
|
||||||
|
- **原因**:`project/write_lock.rs` 的 advisory 复用判据从「自主游戏构建流水线 + 本进程持锁」放宽成「本进程持锁」,而判据只比 `.agent/project.lock` JSON 里的 `pid`。`pid` 只能证明锁由本进程持有,分不清「同一条调用链再次取锁(必须放行,否则自己等自己)」和「本进程另一条写通道正在写(必须继续串行化)」;于是同进程其它线程的写通道也拿到 advisory guard。
|
||||||
|
- **处理**:复用判据收窄到**同线程重入**。新增 `PROJECT_WRITE_LOCK_THREAD_OWNERS`(按锁路径登记真实持锁线程)与 `project_write_lock_reentered_by_current_thread`:登记在 `create_new` 成功处,注销在 guard `Drop` 里并且**按路径**注销(guard 会被移到别的线程再 Drop,例如写入路径交给阻塞线程池的持有者)。只有当前线程就是该路径的持锁线程(或自主游戏构建流水线)才返回 advisory guard;本进程其余争用继续走有界等待与终态占用。
|
||||||
|
- **易错点**:① 用「同线程」近似重入后,靠**同线程自持锁 + 同线程调用**模拟「另一个写者」的用例会失去信号(`agent_runtime_file_write_lock_failure_redacts_project_path`、`recovery_install_respects_the_project_write_lock`):它们必须改成**在另一条线程持锁**,断言才有意义;② 不要用「同进程还有 guard 活着」当重入依据,那等于退回按 `pid` 放行;③ 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会等满预算并在耗尽时报占用,出现这类现场按 2026-08-27 的处置改用 `*_locked` 入口复用已有 guard,不要放宽判据。
|
||||||
|
- **验证**:本地定向 36 条(`--test-threads=1`,过滤 `_after_project_lock` / `bridge_write_file` / `project_write_lock` 等):CI 那 12 条里 9 条转绿(覆盖 `project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `external_generation_state`),3 条在本机被 Windows 临时目录 owner/DACL 挡在 setup(与本次改动无关,见 2026-09-13 条);`project_write_lock_reuses_same_process_owner_and_releases_on_drop`(同线程重入)继续通过。`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 全绿。
|
||||||
|
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`src/agent/runtime_actions/project_gates.rs`(有界等待预算)、`docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-14 项目客户端占用锁收敛」。
|
||||||
|
|
||||||
## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等
|
## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等
|
||||||
|
|
||||||
- **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。
|
- **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。
|
||||||
|
|||||||
@@ -1372,5 +1372,5 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
|||||||
|
|
||||||
## 2026-09-14 项目客户端占用锁收敛
|
## 2026-09-14 项目客户端占用锁收敛
|
||||||
|
|
||||||
项目锁职责收敛为“客户端占用项目”这一事实:同一客户端进程内的嵌套项目写入调用复用已有项目锁并返回 advisory guard,不再等待自身持有的 `.agent/project.lock`;跨进程竞争继续沿用现有占用、残留回收和权限分类。Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍属于进行中的里程碑,完成前不改变其恢复诊断合同。
|
项目锁职责收敛为“客户端占用项目”这一事实:跨进程竞争继续沿用现有占用、残留回收和权限分类。同进程复用的判据收窄到**同一条写调用链(同一线程)重入**——本线程已落盘持有该项目的 `.agent/project.lock` 时再次取锁,返回 advisory guard,不再等待自身持有的锁。本进程**其它线程**的写入通道仍走有界等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装都依赖这把锁把同进程的并发写入串行化,按 `pid` 一律放行会让它们静默竞态。自主游戏构建流水线沿用既有的并行专家动作豁免。Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍属于进行中的里程碑,完成前不改变其恢复诊断合同。
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user