Merge pull request 'Fix/修复测试用例竞态等问题' (#571) from fix/test-concurrent-problem into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m45s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m27s
Project CI / Backend tests (push) Successful in 3m59s
Project CI / Frontend tests (push) Successful in 2m7s
Project CI / Native shell tests (push) Successful in 6m6s
Project CI / AI game creator shell web tests (push) Successful in 1m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 10m20s
Project CI / Repository checks (push) Successful in 2m31s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m42s
Project CI / AI game creator shell Rust crates (push) Successful in 1m45s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m27s
Project CI / Backend tests (push) Successful in 3m59s
Project CI / Frontend tests (push) Successful in 2m7s
Project CI / Native shell tests (push) Successful in 6m6s
Project CI / AI game creator shell web tests (push) Successful in 1m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 10m20s
Project CI / Repository checks (push) Successful in 2m31s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m42s
Reviewed-on: #571
This commit was merged in pull request #571.
This commit is contained in:
@@ -448,7 +448,12 @@ async function runWithConcurrency(shards, runner) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractFailingTestNames(result) {
|
||||
// 只认**本片名单里真实存在**的用例名:先按 `---- <name> stdout ----` / 失败清单行归一化,
|
||||
// 再拿名单做精确匹配。不要再用名字前缀白名单猜用例(历史上只放行 `tests::` 与
|
||||
// `process_session::tests::`,而 bin 目标的用例名形如 `agent::thread_manager::tests::…`,
|
||||
// 于是绝大多数用例永远进不了复核,偶发假红就直接判红)。
|
||||
function extractFailingTestNames(result, shardTestNames) {
|
||||
const known = new Set(shardTestNames);
|
||||
const names = new Set();
|
||||
for (const line of result.failures) {
|
||||
const normalized = line
|
||||
@@ -458,11 +463,8 @@ function extractFailingTestNames(result) {
|
||||
.replace(/\s*stdout\s*----$/, '')
|
||||
.replace(/\s*\(\d+\)\s*$/, '')
|
||||
.trim();
|
||||
const match = normalized.match(
|
||||
/^(process_session::tests::[A-Za-z0-9_:]+|tests::[A-Za-z0-9_:]+)$/,
|
||||
);
|
||||
if (match) {
|
||||
names.add(match[1]);
|
||||
if (known.has(normalized)) {
|
||||
names.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
@@ -519,9 +521,7 @@ async function main() {
|
||||
}
|
||||
// 片内串行的时序型用例在高负载 CI 上会偶发假红。只对失败用例做一次
|
||||
// 有界复核:复核通过按 flaky 记录,复核失败才判红,避免把真实回归洗掉。
|
||||
const failingTestNames = extractFailingTestNames(result).filter((name) =>
|
||||
shardTestNames.includes(name),
|
||||
);
|
||||
const failingTestNames = extractFailingTestNames(result, shardTestNames);
|
||||
if (failingTestNames.length === 0) {
|
||||
return result;
|
||||
}
|
||||
@@ -532,10 +532,13 @@ async function main() {
|
||||
failingTestNames,
|
||||
);
|
||||
if (!retry.ok) {
|
||||
// 复核也失败就判红,但**两次的现场都留**:首次是整片的上下文,复核只跑失败用例,
|
||||
// 丢掉首次现场会让"到底整片跑成什么样"这件事在日志里消失。
|
||||
return {
|
||||
...result,
|
||||
failures: [
|
||||
`re-run of ${failingTestNames.length} failing test(s) also failed`,
|
||||
...result.failures,
|
||||
...retry.failures,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -80,3 +80,25 @@ test('successful shard keeps its compact summary', (t) => {
|
||||
assert.match(result.output, /shard 1\/1 ok: 1 test\(s\)/);
|
||||
assert.doesNotMatch(result.output, /test passing_case \.\.\. ok/);
|
||||
});
|
||||
|
||||
// bin 目标的用例名带命名空间(`agent::thread_manager::tests::…`)。复核名单必须靠
|
||||
// 「本片名单精确匹配」认用例,不能靠名字前缀白名单,否则这类用例永远进不了重跑。
|
||||
test('namespaced failing test is re-run once before the shard is judged', (t) => {
|
||||
const result = runFixture(
|
||||
t,
|
||||
`
|
||||
mod agent {
|
||||
pub mod thread_manager {
|
||||
#[test]
|
||||
fn flaky_case() {
|
||||
assert_eq!(1, 2, "namespaced panic evidence");
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
assert.equal(result.status, 1, result.output);
|
||||
assert.match(result.output, /re-run of 1 failing test\(s\) also failed/);
|
||||
assert.match(result.output, /agent::thread_manager::flaky_case/);
|
||||
assert.match(result.output, /namespaced panic evidence/);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,14 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str =
|
||||
"game-creator-direct-active-turns-changed";
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
#[cfg(test)]
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
thread_local! {
|
||||
/// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主
|
||||
/// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随
|
||||
/// `TurnReservation::drop` 起整轮,见 `thread_manager::dispatch`),并会在任意时刻广播
|
||||
/// 「运行中的项目」变了。断言要观测的是本测试自己触发的通知,不该被别的后台广播串台。
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell<u64> =
|
||||
const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
|
||||
@@ -30,7 +37,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa
|
||||
pub(crate) fn emit_direct_active_turns_changed() {
|
||||
let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
#[cfg(test)]
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel);
|
||||
let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1));
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
@@ -42,7 +49,7 @@ pub(crate) fn emit_direct_active_turns_changed() {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn direct_active_turns_event_test_count() -> u64 {
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire)
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) {
|
||||
|
||||
@@ -1883,10 +1883,24 @@ mod goal_contract_steer_transition_tests {
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
|
||||
assert!(results
|
||||
// 两个并发追加指令里必然有一个是 loser,但 loser 落在哪个错误分支取决于它抢到
|
||||
// transition 锁时 winner 推进到了哪一步:
|
||||
// - winner 的替代 Run 已经写回 session 状态:`已被其他追加指令替换`;
|
||||
// - winner 只终止了旧根 Run、替代 Run 尚未写回:`当前 Agent run 状态不接受追加指令`;
|
||||
// - 读到的状态正好处于切换中途:`追加指令与当前 Agent 的 session/run 身份不匹配`。
|
||||
// 三者都表示这条根 Run 已被另一条追加指令接管,因此这里只钉死“败者被拒绝”这一语义,
|
||||
// 真正的结构不变量(只产生一个替代 Run)由下面的任务日志断言保证。
|
||||
let loser_error = results
|
||||
.iter()
|
||||
.filter_map(|result| result.as_ref().err())
|
||||
.any(|error| error.contains("已被其他追加指令替换")));
|
||||
.next()
|
||||
.expect("concurrent steer loser error");
|
||||
assert!(
|
||||
loser_error.contains("已被其他追加指令替换")
|
||||
|| loser_error.contains("当前 Agent run 状态不接受追加指令")
|
||||
|| loser_error.contains("追加指令与当前 Agent 的 session/run 身份不匹配"),
|
||||
"并发的追加指令 loser 必须以“根 Run 已被接管”类错误收口,实际错误:{loser_error}"
|
||||
);
|
||||
|
||||
let replacement_run_ids = read_all_game_creator_agent_runtime_tasks(
|
||||
&game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||||
|
||||
@@ -1791,7 +1791,8 @@ mod tests {
|
||||
/// 首页快照的变更通知必须跟着快照自己的事实源走:放行(登记占用)、进度内容变化、收口各通知一次,
|
||||
/// 状态与活动都没变的重复进度不重复通知;入队与取消只改队列,不进快照,不通知。
|
||||
///
|
||||
/// 计数器是进程级的,因此本用例与仓库既有的 Rust 定向命令一样按 `--test-threads=1` 跑。
|
||||
/// 计数器按线程隔离:`--test-threads=1` 只串行测试线程,前一个用例放行留下的后台回合仍会在
|
||||
/// 宿主 async runtime 的工作线程上广播通知(见 `direct_active_turns_event_test_count` 的说明)。
|
||||
#[test]
|
||||
fn active_turn_changes_publish_one_notification_per_real_change() {
|
||||
let thread_id = format!("/tmp/事件项目-{}", Uuid::new_v4());
|
||||
|
||||
@@ -4109,34 +4109,6 @@ fn process_session_agent_runtime_confirmed_lifecycle_runs_in_isolated_registry()
|
||||
assert!(status.success(), "isolated lifecycle test failed: {status}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_session_supervisor_collaboration_blocks_stdin_but_allows_poll_and_terminate_in_isolated_registry(
|
||||
) {
|
||||
const CHILD_MARKER: &str =
|
||||
"GENARRATIVE_PROCESS_SESSION_SUPERVISOR_COLLABORATION_GOVERNANCE_CHILD";
|
||||
if std::env::var_os(CHILD_MARKER).is_some() {
|
||||
tauri::async_runtime::block_on(
|
||||
process_session_supervisor_collaboration_governance_fixture(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let status = std::process::Command::new(std::env::current_exe().expect("current test binary"))
|
||||
.env(CHILD_MARKER, "1")
|
||||
.args([
|
||||
"--exact",
|
||||
"tests::command_runtime::process_session_supervisor_collaboration_blocks_stdin_but_allows_poll_and_terminate_in_isolated_registry",
|
||||
"--nocapture",
|
||||
"--test-threads=1",
|
||||
])
|
||||
.status()
|
||||
.expect("spawn isolated Supervisor process collaboration governance test");
|
||||
assert!(
|
||||
status.success(),
|
||||
"isolated Supervisor process collaboration governance test failed: {status}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry() {
|
||||
|
||||
@@ -427,6 +427,11 @@ fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &st
|
||||
/// 因此统一等「状态离开 running 且 lane 可获取」,并留出远大于单机耗时的硬预算。
|
||||
const AGENT_RUNTIME_TERMINAL_WAIT_BUDGET: Duration = Duration::from_secs(60);
|
||||
|
||||
/// mock provider 是进程内 HTTP 服务,但一轮请求要等 Agent Runtime 后台线程跑到对应步骤才会发出。
|
||||
/// CI 上多个 rust shard 与前端任务同机并发时,单轮往返可能被拖过数秒;这里只断言请求确实到达,
|
||||
/// 因此给出远大于单机耗时的硬预算,避免把调度抖动误判成链路故障。请求到达即返回,成功路径零成本。
|
||||
const MOCK_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn wait_for_agent_runtime_lane_release(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -483,7 +488,7 @@ async fn wait_for_captured_mock_request(
|
||||
receiver: &mpsc::Receiver<String>,
|
||||
description: &str,
|
||||
) -> String {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let deadline = Instant::now() + MOCK_PROVIDER_REQUEST_TIMEOUT;
|
||||
loop {
|
||||
match receiver.try_recv() {
|
||||
Ok(request) => return request,
|
||||
@@ -4791,262 +4796,6 @@ async fn execute_approved_process_action_for_test(
|
||||
(pending, observation)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn process_session_supervisor_collaboration_governance_fixture() {
|
||||
const READY_SENTINEL: &str = "SUPERVISOR_PROCESS_READY_PRIVATE";
|
||||
const POLL_SENTINEL: &str = "SUPERVISOR_PROCESS_POLL_PRIVATE";
|
||||
const BLOCKED_STDIN_SENTINEL: &str = "SUPERVISOR_PROCESS_BLOCKED_STDIN_PRIVATE";
|
||||
const AGENT_ID: &str = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
|
||||
const RUN_ID: &str = "supervisor-process-collaboration-governance-run";
|
||||
clear_process_session_registry_for_tests();
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(
|
||||
&root,
|
||||
"supervisor-process-collaboration-governance-project",
|
||||
"Project Supervisor 持久进程治理项目",
|
||||
)
|
||||
.expect("project init");
|
||||
let _cleanup = ProcessSessionIntegrationCleanup {
|
||||
root: root.clone(),
|
||||
agent_id: AGENT_ID,
|
||||
run_id: RUN_ID,
|
||||
};
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"private":true,"scripts":{"dev":"node supervisor-process-fixture.mjs"}}"#,
|
||||
)
|
||||
.expect("write Supervisor process package");
|
||||
fs::write(
|
||||
root.join("supervisor-process-fixture.mjs"),
|
||||
format!(
|
||||
r#"process.stdin.setEncoding('utf8');
|
||||
console.log('{READY_SENTINEL}');
|
||||
process.stdin.on('data', (chunk) => console.log(`ECHO:${{chunk.trim()}}`));
|
||||
setInterval(() => console.log('{POLL_SENTINEL}'), 50);
|
||||
"#
|
||||
),
|
||||
)
|
||||
.expect("write Supervisor process fixture");
|
||||
write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default())
|
||||
.expect("write Supervisor collaboration policy");
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: vec![
|
||||
"command.start".to_string(),
|
||||
"command.stdin".to_string(),
|
||||
"command.terminate".to_string(),
|
||||
],
|
||||
agent_policies: BTreeMap::new(),
|
||||
},
|
||||
)
|
||||
.expect("write process tool policy");
|
||||
let mut state = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
AGENT_ID,
|
||||
"在协作 delivery 前启动持久进程并验证后续治理边界",
|
||||
RUN_ID,
|
||||
"agent-chat",
|
||||
"启动并治理持久进程",
|
||||
vec!["进程可读取且可终止,协作后 stdin 被阻断".to_string()],
|
||||
)
|
||||
.expect("start Supervisor process runtime");
|
||||
state.loop_iteration = 1;
|
||||
|
||||
let (_, start_observation) = execute_approved_process_action_for_test(
|
||||
&root,
|
||||
&mut state,
|
||||
AgentRuntimeToolAction {
|
||||
tool: "command.start".to_string(),
|
||||
reason: Some("在 delivery 前启动真实持久进程".to_string()),
|
||||
input: serde_json::json!({
|
||||
"program": "npm",
|
||||
"args": ["run", "dev"],
|
||||
"cwd": ".",
|
||||
"timeoutSeconds": 30
|
||||
}),
|
||||
},
|
||||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(start_observation.status, "ok", "{start_observation:?}");
|
||||
let start_detail: Value = serde_json::from_str(
|
||||
start_observation
|
||||
.detail
|
||||
.as_deref()
|
||||
.expect("start observation detail"),
|
||||
)
|
||||
.expect("parse start observation detail");
|
||||
let process_id = start_detail["processId"]
|
||||
.as_str()
|
||||
.expect("start processId")
|
||||
.to_string();
|
||||
let mut cursor = start_detail["nextCursor"]
|
||||
.as_str()
|
||||
.expect("start next cursor")
|
||||
.to_string();
|
||||
|
||||
let mut ready_output = String::new();
|
||||
for index in 0..20 {
|
||||
let (_, observation) = execute_approved_process_action_for_test(
|
||||
&root,
|
||||
&mut state,
|
||||
AgentRuntimeToolAction {
|
||||
tool: "command.poll".to_string(),
|
||||
reason: Some(format!("等待 Supervisor fixture ready {index}")),
|
||||
input: serde_json::json!({
|
||||
"processId": process_id,
|
||||
"cursor": cursor,
|
||||
"maxChars": 8_000,
|
||||
"waitMs": 500
|
||||
}),
|
||||
},
|
||||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(observation.status, "ok", "{observation:?}");
|
||||
let detail: Value = serde_json::from_str(
|
||||
observation
|
||||
.detail
|
||||
.as_deref()
|
||||
.expect("ready poll observation detail"),
|
||||
)
|
||||
.expect("parse ready poll detail");
|
||||
assert_eq!(detail["processId"], process_id);
|
||||
ready_output.push_str(detail["output"].as_str().unwrap_or_default());
|
||||
cursor = detail["nextCursor"]
|
||||
.as_str()
|
||||
.expect("ready poll next cursor")
|
||||
.to_string();
|
||||
if ready_output.contains(READY_SENTINEL) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(ready_output.contains(READY_SENTINEL), "{ready_output}");
|
||||
|
||||
let delivery = new_static_delegate_delivery(
|
||||
AGENT_ID,
|
||||
&state.session_id,
|
||||
RUN_ID,
|
||||
"supervisor-process-collaboration-delegate-action",
|
||||
"supervisor-process-collaboration-delivery",
|
||||
"code-prototype",
|
||||
"supervisor-process-collaboration-child-session",
|
||||
"supervisor-process-collaboration-child-run",
|
||||
);
|
||||
create_or_read_static_delegate_delivery_at(&root, &delivery)
|
||||
.expect("create same-run durable static delivery");
|
||||
|
||||
let blocked_stdin = execute_game_creator_agent_runtime_tool_action(
|
||||
&root,
|
||||
AGENT_ID,
|
||||
RUN_ID,
|
||||
&state.current_task,
|
||||
&AgentRuntimeToolAction {
|
||||
tool: "command.stdin".to_string(),
|
||||
reason: Some("协作后不得继续写入持久进程".to_string()),
|
||||
input: serde_json::json!({
|
||||
"processId": process_id,
|
||||
"data": BLOCKED_STDIN_SENTINEL,
|
||||
"appendNewline": true,
|
||||
"eof": false
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(blocked_stdin.status, "blocked", "{blocked_stdin:?}");
|
||||
assert!(blocked_stdin.summary.contains("协作编排"));
|
||||
assert!(!serde_json::to_string(&blocked_stdin)
|
||||
.expect("serialize blocked stdin observation")
|
||||
.contains(BLOCKED_STDIN_SENTINEL));
|
||||
|
||||
let mut post_delivery_output = String::new();
|
||||
for index in 0..20 {
|
||||
let (_, observation) = execute_approved_process_action_for_test(
|
||||
&root,
|
||||
&mut state,
|
||||
AgentRuntimeToolAction {
|
||||
tool: "command.poll".to_string(),
|
||||
reason: Some(format!("协作后继续读取 Supervisor fixture {index}")),
|
||||
input: serde_json::json!({
|
||||
"processId": process_id,
|
||||
"cursor": cursor,
|
||||
"maxChars": 8_000,
|
||||
"waitMs": 500
|
||||
}),
|
||||
},
|
||||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(observation.status, "ok", "{observation:?}");
|
||||
let detail: Value = serde_json::from_str(
|
||||
observation
|
||||
.detail
|
||||
.as_deref()
|
||||
.expect("post-delivery poll detail"),
|
||||
)
|
||||
.expect("parse post-delivery poll detail");
|
||||
assert_eq!(detail["processId"], process_id);
|
||||
let output = detail["output"].as_str().unwrap_or_default();
|
||||
assert!(!output.contains(BLOCKED_STDIN_SENTINEL), "{output}");
|
||||
post_delivery_output.push_str(output);
|
||||
cursor = detail["nextCursor"]
|
||||
.as_str()
|
||||
.expect("post-delivery poll next cursor")
|
||||
.to_string();
|
||||
if post_delivery_output.contains(POLL_SENTINEL) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
post_delivery_output.contains(POLL_SENTINEL),
|
||||
"{post_delivery_output}"
|
||||
);
|
||||
assert!(!post_delivery_output.contains(BLOCKED_STDIN_SENTINEL));
|
||||
let transcript_path = root.join(format!(
|
||||
".agent/runtime/process-sessions/{process_id}.output.json"
|
||||
));
|
||||
let transcript = fs::read_to_string(&transcript_path).expect("read process transcript");
|
||||
assert!(transcript.contains(READY_SENTINEL));
|
||||
assert!(!transcript.contains(BLOCKED_STDIN_SENTINEL));
|
||||
|
||||
let (_, terminate_observation) = execute_approved_process_action_for_test(
|
||||
&root,
|
||||
&mut state,
|
||||
AgentRuntimeToolAction {
|
||||
tool: "command.terminate".to_string(),
|
||||
reason: Some("协作后只治理并终止既有持久进程".to_string()),
|
||||
input: serde_json::json!({
|
||||
"processId": process_id,
|
||||
"cursor": cursor
|
||||
}),
|
||||
},
|
||||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
terminate_observation.status, "ok",
|
||||
"{terminate_observation:?}"
|
||||
);
|
||||
let terminate_detail: Value = serde_json::from_str(
|
||||
terminate_observation
|
||||
.detail
|
||||
.as_deref()
|
||||
.expect("terminate observation detail"),
|
||||
)
|
||||
.expect("parse terminate detail");
|
||||
assert_eq!(terminate_detail["processId"], process_id);
|
||||
assert!(matches!(
|
||||
terminate_detail["status"].as_str(),
|
||||
Some("terminated" | "exited" | "timed-out" | "output-limit-exceeded")
|
||||
));
|
||||
assert!(!has_active_process_sessions_at(&root).expect("process inactive after terminate"));
|
||||
let terminal_transcript =
|
||||
fs::read_to_string(&transcript_path).expect("read terminal process transcript");
|
||||
assert!(!terminal_transcript.contains(BLOCKED_STDIN_SENTINEL));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn process_session_agent_runtime_start_audit_failure_fixture() {
|
||||
const AGENT_ID: &str = "code-prototype";
|
||||
|
||||
@@ -2765,18 +2765,18 @@ async fn background_agent_runtime_can_create_checkpoint_before_file_write() {
|
||||
.expect("start background task");
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("plan llm request");
|
||||
assert!(plan_request.contains("project.checkpoint"));
|
||||
assert!(plan_request.contains("file.write"));
|
||||
let final_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("final reply llm request");
|
||||
assert!(final_request.contains("project.checkpoint"));
|
||||
assert!(final_request.contains("已创建 checkpoint checkpoint-"));
|
||||
assert!(final_request.contains("file.write"));
|
||||
let verified_final_request = receiver
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("final request after checkpoint write verification");
|
||||
assert!(verified_final_request.contains("project.verify"));
|
||||
assert!(verified_final_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK"));
|
||||
|
||||
@@ -721,12 +721,12 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() {
|
||||
.contains("处理 manifest ready 任务:拆解创作方向"));
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("plan llm request");
|
||||
assert!(plan_request.contains("处理 manifest ready 任务:拆解创作方向"));
|
||||
assert!(plan_request.contains("task.update"));
|
||||
let final_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("final reply llm request");
|
||||
assert!(final_request.contains("任务 design-director 已更新为 completed"));
|
||||
|
||||
|
||||
+5
-5
@@ -904,11 +904,11 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser
|
||||
)
|
||||
.expect("start first design task");
|
||||
let first_plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("first design plan request");
|
||||
assert!(first_plan_request.contains("首轮检查连续上下文"));
|
||||
let first_replan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("first design replan request");
|
||||
assert!(first_replan_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环"));
|
||||
let first_runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
@@ -930,7 +930,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser
|
||||
)
|
||||
.expect("start art task");
|
||||
let art_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("art plan request");
|
||||
assert!(art_request.contains("美术无关任务"));
|
||||
assert!(!art_request.contains("design-continuity-first"));
|
||||
@@ -947,7 +947,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser
|
||||
)
|
||||
.expect("start second design task");
|
||||
let second_design_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("second design plan request");
|
||||
assert!(second_design_request.contains("模板 taskId=design-director"));
|
||||
assert!(!second_design_request.contains("contextBoundary:"));
|
||||
@@ -958,7 +958,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser
|
||||
assert!(!second_design_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环"));
|
||||
assert!(!second_design_request.contains("design-continuity-first [completed / completed]"));
|
||||
let second_design_replan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("second design replan request after runtime observation");
|
||||
assert!(second_design_replan_request.contains("agent.run_status"));
|
||||
assert!(second_design_replan_request.contains("conversation.read"));
|
||||
|
||||
@@ -3,7 +3,7 @@ pub(super) use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{mpsc, Arc, Condvar, Mutex as StdMutex},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) use serde_json::Value;
|
||||
@@ -28,6 +28,7 @@ pub(super) use super::super::{
|
||||
wait_for_agent_runtime_terminal_and_lane_release, wait_for_captured_mock_request,
|
||||
write_agent_runtime_task_record_for_test, write_agent_runtime_verification_fixture,
|
||||
write_static_smoke_game_fixture_for_test, write_test_local_config,
|
||||
AGENT_RUNTIME_TERMINAL_WAIT_BUDGET, MOCK_PROVIDER_REQUEST_TIMEOUT,
|
||||
};
|
||||
|
||||
pub(super) use crate::{
|
||||
|
||||
@@ -177,7 +177,7 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() {
|
||||
)
|
||||
.expect("start active task");
|
||||
let first_request = request_receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("first request");
|
||||
assert!(first_request.contains("需要取消后重试的后台任务"));
|
||||
|
||||
@@ -202,7 +202,10 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() {
|
||||
.expect("release first request");
|
||||
let mut cancelled_result =
|
||||
read_game_creator_agent_runtime_at(&root, "design-director").expect("read cancelled");
|
||||
for _ in 0..50 {
|
||||
// 取消收口同样要等后台 worker 真正停稳;CI 上分片并发放大会把它拖过 1 秒,
|
||||
// 因此改用与其它终态等待一致的硬预算,而不是固定 50 × 20ms。
|
||||
let cancel_deadline = Instant::now() + AGENT_RUNTIME_TERMINAL_WAIT_BUDGET;
|
||||
while Instant::now() < cancel_deadline {
|
||||
if game_creator_agent_runtime_task_lock_is_available(&root, "design-director")
|
||||
.expect("inspect cancelled runtime lock")
|
||||
&& cancelled_result.state.status == "cancelled"
|
||||
|
||||
@@ -217,7 +217,7 @@ async fn background_agent_runtime_recovers_bound_context_for_the_same_session_an
|
||||
.expect("resume background task from durable context");
|
||||
assert_eq!(resumed.len(), 1);
|
||||
let request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.unwrap_or_else(|error| {
|
||||
let runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read runtime after recovery timeout")
|
||||
@@ -396,12 +396,12 @@ async fn background_agent_runtime_can_write_memory_and_project_files() {
|
||||
.expect("start background task");
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("plan llm request");
|
||||
assert!(plan_request.contains("memory.write"));
|
||||
assert!(plan_request.contains("file.write"));
|
||||
let second_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("second llm request");
|
||||
assert!(second_request.contains("memory.write"));
|
||||
assert!(second_request.contains("file.write"));
|
||||
@@ -409,7 +409,7 @@ async fn background_agent_runtime_can_write_memory_and_project_files() {
|
||||
assert!(second_request.contains("已写入 long 记忆"));
|
||||
assert!(second_request.contains("已写入 game/agent-notes.md"));
|
||||
let final_request = receiver
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("final llm request after current revision verification");
|
||||
assert!(final_request.contains("project.verify"));
|
||||
assert!(final_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK"));
|
||||
@@ -557,11 +557,11 @@ async fn background_agent_runtime_blocks_cross_agent_private_memory_write() {
|
||||
.expect("start background task");
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("plan llm request");
|
||||
assert!(plan_request.contains("Agent 私有记忆只能由本人写入"));
|
||||
let second_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("second llm request");
|
||||
assert!(second_request.contains("\\\"tool\\\": \\\"memory.write\\\""));
|
||||
assert!(second_request.contains("\\\"status\\\": \\\"blocked\\\""));
|
||||
@@ -648,7 +648,7 @@ async fn background_agent_runtime_file_and_memory_writes_respect_project_policy(
|
||||
.expect("start background task");
|
||||
|
||||
let _plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT)
|
||||
.expect("plan llm request");
|
||||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||||
|
||||
|
||||
@@ -6213,6 +6213,23 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- **注意**:Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages?beta=true`(外加一次 `HEAD /api/hello` 探测),网关不要自己再补 `v1`,用通配段承接客户端协议路径。
|
||||
- **后台协议选项**:`AgcAgentMode` 增加 `anthropic`(显式 Anthropic 协议),`cc` 保留为同一执行器的历史别名;新增目录项应直接写 `anthropic`。
|
||||
|
||||
## 2026-10-01 AGC 壳 Rust 单测片内 `--test-threads=1` 不等于没有并发:宿主后台回合会跨用例发通知
|
||||
|
||||
- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 在 CI 偶发红,`进度内容变化必须通知一次` 拿到 6 而非 5(也见过入队断言就多 1);本地要跑 3–5 次才复现。这条用例靠一个进程级计数器数「运行中的项目」通知。
|
||||
- **原因**:`--test-threads=1` 只串行**测试线程**。上一个用例里 `TurnReservation::drop` → `kick_queue_dispatch` 会用 `tauri::async_runtime::spawn` 起整轮,这轮在 tokio 工作线程上继续跑(项目路径不存在 → 落盘失败 → `complete_turn_if_reserved` → `emit_direct_active_turns_changed`),恰好落在下一个用例的取样窗口里。用工作线程 ID + backtrace 才抓到现场。
|
||||
- **处理(现行口径)**:通知计数器改成线程作用域,只统计本测试线程发出的通知,断言语义(放行 1 / 内容变化 1 / 重复进度 0 / 收口 1)不变。「进程级全局量 + 片内串行 ⇒ 安全」这个假设在 AGC 壳单测里不成立,要么按线程隔离,要么别用全局量当断言依据。
|
||||
- **同时**:分片器的失败复核原先只认 `tests::` / `process_session::tests::` 前缀,而 bin 目标用例名是 `agent::…::tests::…`,于是 3143 条里 2218 条永远进不了复核,偶发假红直接判红;现在按本片名单精确匹配,并且复核也失败时保留首次现场。
|
||||
- **验证**:本机用 CI 那一片(`--shards=4 --shard-index=1` 的第 3 块)连跑:修复前 5 次红 3 次,修复后 18 次全绿;`node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs` 3 项通过。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`。
|
||||
|
||||
## 2026-10-01 AGC 壳 Rust 单测里等 mock provider 请求的 2 秒预算,在 CI 分片并发下偏窄
|
||||
|
||||
- **现象**:`tests::sessions::background_agent_runtime_can_write_memory_and_project_files` 在 CI `agc-rust-shard-4` 偶发红,`sessions.rs:405` 报 `second llm request: Timeout`——首个请求已经通过,只有第二次 provider 往返超时;失败复核同样红,整条用例 3.6 秒就结束。
|
||||
- **原因**:该断言只等了 `Duration::from_secs(2)`。一轮 mock 往返要等 Agent Runtime 后台线程跑到发请求那一步,CI 上 4 个 rust shard 与前端任务同机并发时单轮耗时会被拖到数秒;本机空载实测该请求 0.99–1.29 秒,同机负载下曾到 4.87 秒 / 5.18 秒。链路本身是好的,缺的是预算——这是分片并发放大出来的抖动,不是产品缺陷(同一次失败切回基线 commit 照样复现)。
|
||||
- **处理(现行口径)**:`src/tests/mod.rs` 新增 `MOCK_PROVIDER_REQUEST_TIMEOUT`(30 秒)作为「等一条 mock provider 请求到达」的唯一预算,`wait_for_captured_mock_request` 与 `sessions.rs` 里 7 处 `recv_timeout` 统一引用它;负向断言(确认「不会有下一个请求」的 200ms 窗口)保持不动。请求到达即返回,成功路径零成本,真断链仍会失败,只是失败得更晚。
|
||||
- **验证**:`cargo test --bin genarrative-ai-game-creator-shell tests::sessions:: -- --test-threads=1` 44 passed;`node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 787 tests ok in 88.7s(正是原先失败的那一片)。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs`。
|
||||
|
||||
## 2026-10-01 AGC 官方 Claude 模型缺少账号令牌会被误报为通道断开
|
||||
|
||||
- **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。
|
||||
|
||||
Reference in New Issue
Block a user