0270cd601e
- 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 通过
351 lines
11 KiB
Rust
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)
|
|
}
|