Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs
T
k88936 0270cd601e DirectProject 埋点成绩改由宿主结算,删掉候选/settle 两阶段与 analyticsAttemptId
- analytics/run.rs:删 Request::DirectCandidate / Request::Settle 与 settle();direct_finished 改收放行代次,终态再读一次 identity_generation,不同则整条不记,相同则直接落 Request::Terminal
- analytics/store.rs:删 pending_runs / pending_run_bytes 与两条候选分支(连带 16 条上限),run_request 不再需要字节预算参数
- analytics/gui.rs + main.rs:删 settle_direct_run_analytics 命令与注册
- 放行侧:kick_queue_dispatch 在读 claim 之后读一次平台会话代次并随整轮传下去;user_input 命令不再收 analyticsAttemptId,队列条目不再持有埋点句柄
- 渲染层:删 beginDirectRunAnalytics、句柄表与结算 effect、settle 调用与命令参数;删除 tests/directRunAnalytics.test.ts,appSurface 两处断言同步
- 判据口径随放行搬家:从“发送时与终态同代”改成“放行时与终态同代”,入队后放行前的账号切换不再丢弃成绩
- 验证:cargo test -- analytics::(45 passed,含新增的直写与代际变化两条)、cargo test -- agent::(949 passed)、analytics_real_file_write 用例、npx vitest run tests/appSurface.test.ts(194 passed)、相关 chat/direct 单测 139 passed、typecheck 通过
2026-09-30 16:25:05 +08:00

351 lines
11 KiB
Rust

//! 可观测运行序号和终态消费。双 Agent 各保留一个槽位,不保存历史或身份。
use super::contract::{
self, AgentType, Context, ErrorCode, Event, EventData, Route, RunEndReason, RunFinished,
RunIdentity, RunSource, Source,
};
use super::store::AnalyticsWriter;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const PATH: &str = ".agent/analytics-runs.json";
const MAX_BYTES: usize = 4096;
const MAX_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Metadata {
pub context: Context,
pub run_id: String,
pub terminal_event_id: String,
pub source: Source,
pub run_source: RunSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_revision: Option<String>,
}
impl Metadata {
pub(crate) fn new(context: Context, source: Source, run_source: RunSource) -> Self {
Self {
context,
source,
run_source,
run_id: uuid::Uuid::new_v4().to_string(),
terminal_event_id: uuid::Uuid::new_v4().to_string(),
output_revision: None,
}
}
fn validate(&self) -> bool {
matches!(self.source, Source::Direct | Source::DesignAgent)
&& uuid::Uuid::parse_str(&self.run_id).is_ok()
&& uuid::Uuid::parse_str(&self.terminal_event_id).is_ok()
&& self.context.route.validate()
&& self.output_revision.as_deref().is_none_or(valid_id)
}
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct Outcome {
pub turn_id: Option<String>,
pub end_reason: RunEndReason,
pub error_code: Option<ErrorCode>,
pub duration_ms: Option<u64>,
pub output_change_detected: Option<bool>,
pub revision_id: Option<String>,
}
#[derive(Serialize)]
pub(super) enum Request {
Accepted {
root: PathBuf,
project_id: String,
metadata: Metadata,
},
Terminal {
root: PathBuf,
project_id: String,
metadata: Metadata,
context: Context,
event_time: String,
outcome: Outcome,
},
}
impl Request {
pub(super) fn session_id(&self) -> &str {
match self {
Self::Accepted { metadata, .. } => &metadata.context.editor_session_id,
Self::Terminal { context, .. } => &context.editor_session_id,
}
}
pub(super) fn validate(&self) -> bool {
let (root, project_id, metadata) = match self {
Self::Accepted {
root,
project_id,
metadata,
} => (root, project_id, metadata),
Self::Terminal {
root,
project_id,
metadata,
..
} => (root, project_id, metadata),
};
root.is_absolute()
&& root.as_os_str().len() <= 32768
&& valid_id(project_id)
&& metadata.validate()
}
}
pub(crate) fn accepted(
writer: &AnalyticsWriter,
root: &Path,
project_id: &str,
metadata: &Metadata,
) {
writer.try_run(Request::Accepted {
root: root.into(),
project_id: project_id.into(),
metadata: metadata.clone(),
});
}
pub(crate) fn finished(
capture: Option<(Context, AnalyticsWriter)>,
root: &Path,
project_id: &str,
metadata: &Metadata,
outcome: Outcome,
) {
let Some((mut context, writer)) = capture else {
return;
};
// 恢复后的 GUI 会话属于新实例,但账号和目标平台仍属于原运行。
context.route = metadata.context.route.clone();
writer.try_run(Request::Terminal {
root: root.into(),
project_id: project_id.into(),
metadata: metadata.clone(),
context,
event_time: contract::timestamp_now(),
outcome,
});
}
/// Direct 回合终态:成绩由宿主自己结算,中间没有候选表,也没有第二次 settle。
///
/// `release_identity_generation` 是放行那一刻的身份代次(由放行侧读并随这一轮传下来)。
/// 终态再读一次,不同就整条不记:换了号 / 退出之后这一轮的成绩不属于任何在册身份。
/// 判据口径因此从"发送时与终态同代"变成"放行时与终态同代"——入队后、放行前发生的账号切换
/// 不再丢弃成绩,那一轮确实是在新身份下跑的。
pub(crate) fn direct_finished(
capture: Option<(Context, AnalyticsWriter)>,
root: &Path,
project_id: &str,
metadata: &Metadata,
release_identity_generation: u64,
outcome: Outcome,
) {
if metadata.source != Source::Direct {
return;
}
if crate::platform_session::current_platform_session_write_state().identity_generation
!= release_identity_generation
{
return;
}
finished(capture, root, project_id, metadata, outcome);
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Slot {
run_id: String,
retry_index: u64,
terminal_consumed: bool,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct State {
schema_version: u32,
project_id: String,
retry_count: u64,
direct: Option<Slot>,
design: Option<Slot>,
}
impl State {
fn slot(&mut self, source: Source) -> &mut Option<Slot> {
if source == Source::Direct {
&mut self.direct
} else {
&mut self.design
}
}
fn validate(&self, project_id: &str) -> bool {
self.schema_version == 1
&& self.project_id == project_id
&& self.retry_count <= MAX_INTEGER
&& [&self.direct, &self.design]
.into_iter()
.flatten()
.all(|slot| {
uuid::Uuid::parse_str(&slot.run_id).is_ok()
&& slot.retry_index <= self.retry_count
})
}
}
fn valid_id(value: &str) -> bool {
!value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
}
pub(super) fn process(request: Request) -> Result<Option<(Route, Event)>, String> {
if !request.validate() {
return Ok(None);
}
let (root, project_id, metadata) = match &request {
Request::Accepted {
root,
project_id,
metadata,
}
| Request::Terminal {
root,
project_id,
metadata,
..
} => (root, project_id, metadata),
};
let Some(_lock) =
crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-runs")?
else {
return Ok(None);
};
let manifest_path = crate::project::resolve_local_project_path(root, ".agent/manifest.json")?;
if crate::project::read_manifest(&manifest_path)?.project_id != *project_id {
return Ok(None);
}
let path = crate::project::resolve_local_project_path(root, PATH)?;
let mut state = match std::fs::symlink_metadata(&path) {
Ok(meta) if meta.is_file() && !meta.file_type().is_symlink() => {
let Some(state): Option<State> =
crate::agent::read_agent_runtime_json_sidecar_with_max_bytes(
root,
PATH,
"运行埋点序号",
MAX_BYTES,
)?
else {
return Ok(None);
};
if !state.validate(project_id) {
return Ok(None);
}
state
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if !matches!(request, Request::Accepted { .. }) {
return Ok(None);
}
let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path);
if !matches!(std::fs::symlink_metadata(backup), Err(ref e) if e.kind() == std::io::ErrorKind::NotFound)
{
return Ok(None);
}
State {
schema_version: 1,
project_id: project_id.clone(),
retry_count: 0,
direct: None,
design: None,
}
}
_ => return Ok(None),
};
let event = match &request {
Request::Accepted { .. } => {
if state
.slot(metadata.source)
.as_ref()
.is_some_and(|slot| slot.run_id == metadata.run_id)
{
return Ok(None);
}
if metadata.run_source == RunSource::UserRetry {
if state.retry_count == MAX_INTEGER {
return Ok(None);
}
state.retry_count += 1;
}
let ordinal = state.retry_count;
*state.slot(metadata.source) = Some(Slot {
run_id: metadata.run_id.clone(),
retry_index: ordinal,
terminal_consumed: false,
});
None
}
Request::Terminal {
context,
event_time,
outcome,
..
} => {
let Some(slot) = state.slot(metadata.source) else {
return Ok(None);
};
if slot.run_id != metadata.run_id || slot.terminal_consumed {
return Ok(None);
}
let data = RunFinished {
agent_type: if metadata.source == Source::Direct {
AgentType::GameAgent
} else {
AgentType::DesignAgent
},
run_source: metadata.run_source,
duration_ms: outcome.duration_ms,
retry_index: slot.retry_index,
output_change_detected: outcome.output_change_detected,
revision_id: outcome.revision_id.clone(),
end_reason: outcome.end_reason,
};
let data = if outcome.end_reason == RunEndReason::Failed {
EventData::AgentRunFailed(data)
} else {
EventData::AgentRunCompleted(data)
};
let Ok(mut event) = context.capture(
data,
Some(project_id.clone()),
metadata.source,
Some(RunIdentity {
run_id: metadata.run_id.clone(),
turn_id: outcome.turn_id.clone(),
error_code: outcome.error_code,
}),
) else {
return Ok(None);
};
event.event_id = metadata.terminal_event_id.clone();
event.event_time = event_time.clone();
if event.validate().is_err() {
return Ok(None);
}
slot.terminal_consumed = true;
Some((context.route.clone(), event))
}
};
crate::agent::write_agent_runtime_json_sidecar_with_max_bytes(
root,
PATH,
"运行埋点序号",
&state,
MAX_BYTES,
)?;
Ok(event)
}