原子化Direct线程与历史回放判定

让thread_for在同一锁内返回线程是否新建。

仅新建Direct线程时读取project.jsonl并注入历史。

保持已有线程只发送当前用户消息。
This commit is contained in:
2026-09-02 20:12:41 +08:00
parent b136a7c346
commit 8ed2f42b52
@@ -1417,11 +1417,6 @@ fn trust_isolated_game_creator_codex_workspace(
}
impl CodexAppServerConnection {
async fn has_thread_for(&self, snapshot: &AgentRuntimeProviderRequestSnapshot) -> bool {
let key = CodexNodeThreadKey::from(snapshot);
self.inner.threads.lock().await.contains_key(&key)
}
async fn acquire(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
@@ -1948,17 +1943,23 @@ impl CodexAppServerConnection {
snapshot: &AgentRuntimeProviderRequestSnapshot,
request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
) -> Result<CodexThreadLease, platform_llm::LlmError> {
) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> {
let key = CodexNodeThreadKey::from(snapshot);
let mut threads = self.inner.threads.lock().await;
// The caller holds `turn_gate` while invoking this method. Returning
// the creation bit from the same threads lock keeps the replay
// decision atomic with thread reuse/creation.
if let Some(entry) = threads.get_mut(&key) {
entry.active_uses = entry.active_uses.saturating_add(1);
entry.last_used = next_game_creator_codex_app_server_usage_tick();
return Ok(CodexThreadLease {
connection: self.clone(),
key,
thread_id: entry.thread_id.clone(),
});
return Ok((
CodexThreadLease {
connection: self.clone(),
key,
thread_id: entry.thread_id.clone(),
},
false,
));
}
if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX {
let evict_key = threads
@@ -2020,11 +2021,14 @@ impl CodexAppServerConnection {
active_uses: 1,
},
);
Ok(CodexThreadLease {
connection: self.clone(),
key,
thread_id,
})
Ok((
CodexThreadLease {
connection: self.clone(),
key,
thread_id,
},
true,
))
}
async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver<CodexTurnEvent> {
@@ -2075,6 +2079,7 @@ impl CodexAppServerConnection {
snapshot,
llm,
request,
None,
on_agent_message_delta,
None,
None,
@@ -2087,14 +2092,37 @@ impl CodexAppServerConnection {
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: LlmRunRequest,
direct_client_turn_id: Option<&str>,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
self.wait_for_initial_client_mcp_startup().await;
let thread_id = thread_lease.thread_id.clone();
let mut request = request;
if thread_created && self.inner.workspace_mode.uses_direct_conversation() {
// DirectProject intentionally replays the complete append-only
// project history. AGC owns that durable fact source; keeping
// the replay lossless is the product contract even though a
// future context-budget policy may need to change this choice.
let current_prompt = direct_codex_current_user_prompt(&request).to_string();
let history_prompt = build_direct_codex_history_prompt(
&self.inner.workspace_path,
direct_client_turn_id.unwrap_or("__none__"),
&current_prompt,
)
.map_err(platform_llm::LlmError::InvalidRequest)?;
if let Some(message) = request
.messages
.iter_mut()
.rev()
.find(|message| message.role == LlmMessageRole::User)
{
message.content = history_prompt;
}
}
let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
direct_codex_user_prompt(&request)
} else {
@@ -3145,22 +3173,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
)
.await
.map_err(|error| error.to_string())?;
let user_prompt = if connection.has_thread_for(&snapshot).await {
user_prompt
} else {
build_direct_codex_history_prompt(
&codex_root,
client_turn_id.unwrap_or("__none__"),
&user_prompt,
)?
};
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms)
.with_max_output_tokens(16_000);
connection
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
.run_turn_with_direct_observer(
&snapshot,
&config.llm,
request,
client_turn_id,
None,
observer,
audit,
)
.await
.map(|value| value.text)
.map_err(|error| error.to_string())
@@ -3173,6 +3200,9 @@ pub(crate) fn build_direct_codex_history_prompt(
) -> Result<String, String> {
let conversation = read_local_conversation_for_session_at(root, None, None)?;
let current_message_id = format!("direct-codex:{client_turn_id}:user");
// The deliberately simple role-prefix format is part of the Direct
// replay contract. Do not introduce an envelope or implicit escaping
// here without updating the persisted-history compatibility decision.
let mut lines = conversation
.messages
.iter()
@@ -4622,6 +4652,7 @@ while IFS= read -r line; do :; done
&test_snapshot(),
&llm,
tool_request(),
None,
Some(&mut on_delta),
Some(&mut observer),
None,