修复工具调用落盘状态无单调性(P1)
- direct_tool_calls.rs:把 preserve_started_at(只管 startedAt)换成 merge_tool_call_snapshot, 同一 id 的多条快照按 updatedAt 单调合并:updatedAt 更旧的快照不得覆盖更新的 status 与 updatedAt, updatedAt 相同时终态优先,startedAt 仍取最早的非零值 - upsert_direct_tool_call_once(逐条快照落盘)与 persist_direct_tool_calls_at(回合末整批落盘) 两条竞争路径共用同一合并函数,后到的旧快照不再把 completed / failed 打回 running - normalize_tool_calls(回读归并)同 id 的重复行同样按 updatedAt 合并,磁盘上留下的旧快照不得回退状态 - 新增单测:tool_call_persist_keeps_newest_snapshot_per_item(单条 + 整批两条路径)、 tool_call_read_merges_duplicate_rows_monotonically(回读重复行)
This commit is contained in:
@@ -346,11 +346,16 @@ fn read_tool_call_lines(path: &Path) -> Vec<DirectToolCall> {
|
||||
calls
|
||||
}
|
||||
|
||||
/// 按 id 归并(后写覆盖先写),再按时间正序裁剪到最近 `DIRECT_TOOL_CALL_LIMIT` 条。
|
||||
/// 按 id 归并(同 id 按 `updatedAt` 单调合并),再按时间正序裁剪到最近
|
||||
/// `DIRECT_TOOL_CALL_LIMIT` 条。
|
||||
fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
|
||||
let mut by_id: BTreeMap<String, DirectToolCall> = BTreeMap::new();
|
||||
for call in calls {
|
||||
by_id.insert(call.id.clone(), call);
|
||||
let merged = match by_id.remove(&call.id) {
|
||||
Some(previous) => merge_tool_call_snapshot(&previous, &call),
|
||||
None => call,
|
||||
};
|
||||
by_id.insert(merged.id.clone(), merged);
|
||||
}
|
||||
let mut normalized = by_id.into_values().collect::<Vec<_>>();
|
||||
normalized.sort_by(|left, right| {
|
||||
@@ -373,20 +378,43 @@ pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCal
|
||||
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
|
||||
}
|
||||
|
||||
/// 同一 id 的 `startedAt` 取最早的非零值:`item/completed` 事件不一定带
|
||||
/// `startedAtMs`,不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。
|
||||
fn preserve_started_at(incoming: &mut DirectToolCall, existing: Option<&DirectToolCall>) {
|
||||
let Some(existing) = existing else {
|
||||
return;
|
||||
};
|
||||
if existing.started_at > 0
|
||||
&& (incoming.started_at == 0 || existing.started_at < incoming.started_at)
|
||||
{
|
||||
incoming.started_at = existing.started_at;
|
||||
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
|
||||
fn status_certainty(status: &str) -> u8 {
|
||||
match status {
|
||||
"completed" | "failed" => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 幂等 upsert:同 id 只保留一行,`completed` 覆盖 `started`。
|
||||
/// 同一 id 的两条快照按 `updatedAt` 做**单调合并**。
|
||||
///
|
||||
/// - `startedAt` 取最早的非零值:`item/completed` 事件不一定带 `startedAtMs`,
|
||||
/// 不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。
|
||||
/// - `updatedAt` 更旧的快照不得覆盖更新的状态与 `updatedAt`:`direct_runtime.rs` 里
|
||||
/// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的
|
||||
/// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。
|
||||
/// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。
|
||||
fn merge_tool_call_snapshot(
|
||||
existing: &DirectToolCall,
|
||||
incoming: &DirectToolCall,
|
||||
) -> DirectToolCall {
|
||||
let take_incoming = incoming.updated_at > existing.updated_at
|
||||
|| (incoming.updated_at == existing.updated_at
|
||||
&& status_certainty(&incoming.status) > status_certainty(&existing.status));
|
||||
let mut merged = if take_incoming {
|
||||
incoming.clone()
|
||||
} else {
|
||||
existing.clone()
|
||||
};
|
||||
merged.started_at = [merged.started_at, existing.started_at, incoming.started_at]
|
||||
.into_iter()
|
||||
.filter(|started_at| *started_at > 0)
|
||||
.min()
|
||||
.unwrap_or_default();
|
||||
merged
|
||||
}
|
||||
|
||||
/// 幂等 upsert:同 id 只保留一行,快照按 `updatedAt` 单调合并(旧快照不得回退状态)。
|
||||
///
|
||||
/// 单次尝试的顺序是「取项目锁 + append 锁 → 锁内读 → 整文件原子替换」。
|
||||
/// 工具调用是**追加 + 就地更新**混用的数据,没有纯追加的 JSONL 语义,所以只能整文件重写;
|
||||
@@ -404,9 +432,11 @@ fn upsert_direct_tool_call_once(root: &Path, call: &DirectToolCall) -> Result<()
|
||||
.iter()
|
||||
.find(|existing| existing.id == call.id)
|
||||
.cloned();
|
||||
let mut incoming = call.clone();
|
||||
preserve_started_at(&mut incoming, existing.as_ref());
|
||||
calls.retain(|existing| existing.id != call.id);
|
||||
let incoming = match existing.as_ref() {
|
||||
Some(existing) => merge_tool_call_snapshot(existing, call),
|
||||
None => call.clone(),
|
||||
};
|
||||
calls.retain(|existing| existing.id != incoming.id);
|
||||
calls.push(incoming);
|
||||
let normalized = normalize_tool_calls(calls);
|
||||
let mut body = String::new();
|
||||
@@ -445,11 +475,13 @@ pub(crate) fn persist_direct_tool_calls_at(
|
||||
let mut existing = read_tool_call_lines(&path);
|
||||
let mut incoming = calls.to_vec();
|
||||
for call in incoming.iter_mut() {
|
||||
let previous = existing
|
||||
let merged = existing
|
||||
.iter()
|
||||
.find(|existing| existing.id == call.id)
|
||||
.cloned();
|
||||
preserve_started_at(call, previous.as_ref());
|
||||
.find(|row| row.id == call.id)
|
||||
.map(|previous| merge_tool_call_snapshot(previous, call));
|
||||
if let Some(merged) = merged {
|
||||
*call = merged;
|
||||
}
|
||||
}
|
||||
let ids = incoming
|
||||
.iter()
|
||||
@@ -484,6 +516,26 @@ mod tests {
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
/// 一行合法的落盘信封(回读用例的夹具)。
|
||||
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
|
||||
serde_json::to_string(&json!({
|
||||
"type": "tool_call_item",
|
||||
"payload": {
|
||||
"schemaVersion": "agc-tool-call.v1",
|
||||
"id": id,
|
||||
"turnId": "turn-1",
|
||||
"kind": "command",
|
||||
"title": "执行命令",
|
||||
"summary": "npm run build",
|
||||
"status": "completed",
|
||||
"detail": {"command": "npm run build"},
|
||||
"startedAt": started_at,
|
||||
"updatedAt": updated_at
|
||||
}
|
||||
}))
|
||||
.expect("serialize tool call row")
|
||||
}
|
||||
|
||||
fn init_tool_call_project(name: &str) -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), name, "工具调用卡片测试")
|
||||
@@ -875,4 +927,79 @@ mod tests {
|
||||
assert_eq!(sanitize(&once), once, "脱敏必须幂等");
|
||||
}
|
||||
|
||||
/// 判据:同 id 的快照按 `updatedAt` 单调合并——后到的旧快照不得把终态打回 `running`,
|
||||
/// 也不得回退 `updatedAt`;`startedAt` 仍取最早。
|
||||
#[test]
|
||||
fn tool_call_persist_keeps_newest_snapshot_per_item() {
|
||||
let root = init_tool_call_project("tool-call-monotonic");
|
||||
let running = direct_tool_call_from_item(
|
||||
root.path(),
|
||||
&command_item("item-1", "npm run build"),
|
||||
"turn-1",
|
||||
false,
|
||||
1000,
|
||||
)
|
||||
.expect("running tool call");
|
||||
assert_eq!(running.status, "running");
|
||||
let completed = direct_tool_call_from_item(
|
||||
root.path(),
|
||||
&json!({
|
||||
"id": "item-1",
|
||||
"type": "commandExecution",
|
||||
"command": "npm run build",
|
||||
"exitCode": 0,
|
||||
"completedAtMs": 2000,
|
||||
}),
|
||||
"turn-1",
|
||||
true,
|
||||
2000,
|
||||
)
|
||||
.expect("completed tool call");
|
||||
assert_eq!(completed.status, "completed");
|
||||
assert_eq!(completed.updated_at, 2000);
|
||||
|
||||
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
|
||||
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write");
|
||||
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"后到的旧快照不得把 completed 打回 running"
|
||||
);
|
||||
assert_eq!(calls[0].updated_at, 2000, "旧快照不得回退 updatedAt");
|
||||
assert_eq!(calls[0].started_at, 1000, "startedAt 仍取最早");
|
||||
|
||||
// 回合末整批落盘那条路径同样不得回退。
|
||||
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
|
||||
.expect("persist stale running batch");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"整批落盘路径同样不得把 completed 打回 running"
|
||||
);
|
||||
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
|
||||
}
|
||||
|
||||
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
|
||||
#[test]
|
||||
fn tool_call_read_merges_duplicate_rows_monotonically() {
|
||||
let root = init_tool_call_project("tool-call-read-monotonic");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
let completed = tool_call_row("item-1", 1000, 2000);
|
||||
let stale_running = tool_call_row("item-1", 1000, 1000)
|
||||
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
|
||||
assert!(stale_running.contains("\"status\":\"running\""));
|
||||
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
|
||||
assert_eq!(calls.len(), 1, "同 id 归并成一条");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"磁盘上更旧的快照不得把状态打回 running"
|
||||
);
|
||||
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
|
||||
assert_eq!(calls[0].started_at, 1000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user