修复 Direct 回合收尾与进程退出判断
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m20s
Project CI / Backend tests (pull_request) Successful in 8m32s
Project CI / Frontend tests (pull_request) Successful in 3m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m59s
Project CI / Repository checks (pull_request) Successful in 5m12s
Project CI / Native shell tests (pull_request) Successful in 6m57s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m20s
Project CI / Backend tests (pull_request) Successful in 8m32s
Project CI / Frontend tests (pull_request) Successful in 3m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m59s
Project CI / Repository checks (pull_request) Successful in 5m12s
Project CI / Native shell tests (pull_request) Successful in 6m57s
使用受控归属退出证明完成 Unix 回合,保留完整子树证明边界 保守处理 Linux 进程扫描错误,避免将未确认的活跃进程判为空组 优先返回收尾时已持久化的预算耗尽报告,保留其他关闭错误 阻止正常关闭的迟到通知创建或覆盖成功回合报告 补充进程扫描、完整响应和预算回归测试并同步规范与验收记录
This commit is contained in:
@@ -892,7 +892,7 @@ impl ExecutionAdapter {
|
||||
/// 中断会话,`closed` 与阶段都要等那个任务跑到才变,所以"标志已置、阶段未变"的窗口里到达的
|
||||
/// 通道断开 / 中断都是宿主自己收尾的结果,不能记成 `transport-failed`。
|
||||
///
|
||||
/// 不记失败事实不等于不收束:原因照样写进报告(`interrupt` 会把它追加进去),便于核对。
|
||||
/// 非宿主关闭仍记录失败原因;宿主关闭的通知只在失败终态追加,正常回复不受影响。
|
||||
///
|
||||
/// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个
|
||||
/// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态
|
||||
@@ -904,8 +904,7 @@ impl ExecutionAdapter {
|
||||
let reason = failure.diagnostic_detail();
|
||||
if self.is_closed() {
|
||||
// 宿主自己收尾:连接是我们先关的,紧随其后的 `TransportClosed` 只是收尾的副产物。
|
||||
// 只把原因留给报告,不改阶段——否则正常的宿主收尾会被改写成 `interrupted`
|
||||
// 并把回执文案换成「执行通道已断开」(见 pitfalls 2026-09-28)。
|
||||
// 不改阶段,且仅在已有失败终态追加说明,避免正常回合被通道关闭报告截断。
|
||||
let session = Arc::clone(&self.session);
|
||||
let note = reason.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || session.append_terminal_note(note)).await;
|
||||
@@ -1115,7 +1114,7 @@ impl ExecutionAdapter {
|
||||
self.closed.store(true, Ordering::Release);
|
||||
let proven = shutdown_game_creator_codex_app_server_inner(inner, "宿主执行预算或交付收尾")
|
||||
.await
|
||||
.map(|proof| proof.confirmed())
|
||||
.map(|proof| proof.owned_scope_retired())
|
||||
.unwrap_or(false);
|
||||
self.drain().await;
|
||||
let session = Arc::clone(&self.session);
|
||||
@@ -1187,7 +1186,7 @@ impl ExecutionAdapter {
|
||||
"模型本次执行结束,回收原生后台子树",
|
||||
)
|
||||
.await
|
||||
.map(|proof| proof.confirmed())
|
||||
.map(|proof| proof.owned_scope_retired())
|
||||
.unwrap_or(false);
|
||||
self.drain().await;
|
||||
let session = Arc::clone(&self.session);
|
||||
@@ -1485,8 +1484,13 @@ mod tests {
|
||||
|
||||
assert!(adapter.turn_failure().is_none());
|
||||
assert!(adapter.host_stop_requested());
|
||||
// 原因照样进报告:不算失败不等于不用记。
|
||||
assert!(adapter.report().contains("模型本次执行结束"));
|
||||
// 正常关闭通知不能为尚未完成的回合创建终态报告。
|
||||
assert!(adapter
|
||||
.session
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.terminal_report
|
||||
.is_none());
|
||||
// 阶段不能被改成 Interrupted:宿主自己收尾时,账本必须保留它自己的结论。
|
||||
// 这一条是 2026-09-28「CLI 每轮回执都变成『执行通道已断开』」缺陷的回归判据——
|
||||
// 缺陷版本里 `fail_turn` 会无条件 `session.interrupt(reason)`,把阶段打成 Interrupted。
|
||||
|
||||
@@ -8242,17 +8242,23 @@ done
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() {
|
||||
run_direct_response_fixture(false).await;
|
||||
run_direct_response_fixture(false, false).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn no_contract_operation_completes_with_original_response_after_group_shutdown() {
|
||||
run_direct_response_fixture(false, true).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn ready_contract_allows_operation_then_complete_response_before_host_shutdown() {
|
||||
run_direct_response_fixture(true).await;
|
||||
run_direct_response_fixture(true, true).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_direct_response_fixture(ready_contract: bool) {
|
||||
async fn run_direct_response_fixture(ready_contract: bool, has_operation: bool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
@@ -8283,7 +8289,7 @@ while IFS= read -r line; do
|
||||
esac
|
||||
done
|
||||
"#;
|
||||
let script = if ready_contract {
|
||||
let script = if has_operation {
|
||||
script.replace(" printf '%s\\n' '{\"method\":\"item/agentMessage/delta\"", r#" printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"cmd-ready","type":"commandExecution","status":"inProgress"}}}'
|
||||
printf '%s\n' '{"id":999,"method":"item/commandExecution/requestApproval","params":{"threadId":"thread-echo","turnId":"turn-echo","itemId":"cmd-ready"}}'
|
||||
IFS= read -r approval
|
||||
@@ -8360,10 +8366,11 @@ done
|
||||
snapshot.project_id = direct_codex_canonical_project_identity(&project)
|
||||
.expect("canonical Provider snapshot identity")
|
||||
.1;
|
||||
let _execution_guard = super::super::direct_execution::register_for_test(execution)
|
||||
.expect("register host execution");
|
||||
let _execution_guard =
|
||||
super::super::direct_execution::register_for_test(Arc::clone(&execution))
|
||||
.expect("register host execution");
|
||||
let mut observer = |_observation| {};
|
||||
connection
|
||||
let response = connection
|
||||
.run_turn_with_direct_observer_and_history(
|
||||
&snapshot,
|
||||
&llm,
|
||||
@@ -8379,6 +8386,42 @@ done
|
||||
.expect("run direct-project turn");
|
||||
drop(observer);
|
||||
|
||||
if has_operation {
|
||||
let proof = connection
|
||||
.inner
|
||||
.process_tree
|
||||
.recorded_exit_proof()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(proof.owned_scope_retired());
|
||||
assert!(!proof.confirmed(), "Unix 退出仍只证明受控进程组范围");
|
||||
assert!(execution.snapshot().unwrap().executor_stopped);
|
||||
}
|
||||
if !ready_contract {
|
||||
assert_eq!(
|
||||
execution.snapshot().unwrap().phase,
|
||||
super::super::direct_execution::ExecutionPhase::Working
|
||||
);
|
||||
}
|
||||
let report = super::super::direct_delivery::review_reply(&project, &execution)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = execution.snapshot().unwrap();
|
||||
assert_eq!(
|
||||
state.phase,
|
||||
super::super::direct_execution::ExecutionPhase::Completed
|
||||
);
|
||||
if ready_contract {
|
||||
let report = report.expect("已就绪合同必须返回宿主验收报告");
|
||||
assert!(report.contains("本轮已完成宿主验收"));
|
||||
assert_eq!(response.text, report);
|
||||
assert_eq!(state.terminal_report.as_deref(), Some(report.as_str()));
|
||||
} else {
|
||||
assert_eq!(response.text, "好的");
|
||||
assert!(report.is_none());
|
||||
assert!(state.terminal_report.is_none());
|
||||
}
|
||||
|
||||
let consumed =
|
||||
crate::agent::consume_thread(&bootstrap.subscription_id).expect("consume events");
|
||||
// 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个
|
||||
|
||||
@@ -20,7 +20,7 @@ impl ProcessTreeExitProof {
|
||||
self.main_process_exited && self.observed_tree_empty && self.full_tree_verified
|
||||
}
|
||||
|
||||
/// 生命周期退役仅证明已控制的归属为空;不能替代验收使用的完整子树证明。
|
||||
/// 回合收尾及交付只要求受控归属退役;进程组不能冒充完整子树证明。
|
||||
pub(super) fn owned_scope_retired(&self) -> bool {
|
||||
match self.scope {
|
||||
"windows-job" => self.confirmed(),
|
||||
@@ -191,28 +191,80 @@ impl OwnedProcessTree {
|
||||
/// 僵尸对退役语义等价于已退出,扫描 /proc 时只把非僵尸成员视为存活。
|
||||
#[cfg(all(unix, target_os = "linux"))]
|
||||
fn linux_process_group_has_live_member(pgid: i32) -> Option<bool> {
|
||||
for entry in std::fs::read_dir("/proc").ok()? {
|
||||
let entry = entry.ok()?;
|
||||
let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) else {
|
||||
linux_process_group_members(
|
||||
pgid,
|
||||
std::fs::read_dir("/proc")
|
||||
.ok()?
|
||||
.map(|entry| entry.map(|entry| entry.path())),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_process_group_members(
|
||||
pgid: i32,
|
||||
entries: impl IntoIterator<Item = std::io::Result<std::path::PathBuf>>,
|
||||
) -> Option<bool> {
|
||||
let mut uncertain = false;
|
||||
for entry in entries {
|
||||
let Ok(path) = entry else {
|
||||
uncertain = true;
|
||||
continue;
|
||||
};
|
||||
if !path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let stat = match std::fs::read_to_string(path.join("stat")) {
|
||||
Ok(stat) => stat,
|
||||
// /proc 枚举与读取之间 PID 可消失;仅在确认目录已消失时忽略。
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::NotFound
|
||||
&& path.try_exists().is_ok_and(|exists| !exists) =>
|
||||
{
|
||||
continue
|
||||
}
|
||||
Err(_) => {
|
||||
uncertain = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(close) = stat.rfind(')') else {
|
||||
uncertain = true;
|
||||
continue;
|
||||
};
|
||||
let mut fields = stat[close + 1..].split_whitespace();
|
||||
let (Some(state), Some(_ppid), Some(member_pgid)) =
|
||||
(fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
uncertain = true;
|
||||
continue;
|
||||
};
|
||||
if member_pgid.parse::<i32>().ok() != Some(pgid) {
|
||||
let Ok(member_pgid) = member_pgid.parse::<i32>() else {
|
||||
uncertain = true;
|
||||
continue;
|
||||
};
|
||||
if member_pgid != pgid {
|
||||
continue;
|
||||
}
|
||||
if !matches!(
|
||||
state,
|
||||
"R" | "S" | "D" | "Z" | "T" | "t" | "X" | "x" | "K" | "W" | "P" | "I"
|
||||
) {
|
||||
uncertain = true;
|
||||
continue;
|
||||
}
|
||||
if state != "Z" {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
if uncertain {
|
||||
None
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedProcessTree {
|
||||
@@ -231,6 +283,115 @@ mod tests {
|
||||
const FIXTURE: &str =
|
||||
"agent::codex_app_server::process_tree::tests::owned_tree_process_fixture";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn process_scan_distinguishes_live_zombie_foreign_and_disappeared_pids() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let zombie = root.path().join("101");
|
||||
let foreign = root.path().join("102");
|
||||
let live = root.path().join("103");
|
||||
for (path, stat) in [
|
||||
(&zombie, "101 (name with ) brackets) Z 1 100"),
|
||||
(&foreign, "102 (other) R 1 200"),
|
||||
(&live, "103 (worker) S 1 100"),
|
||||
] {
|
||||
std::fs::create_dir(path).unwrap();
|
||||
std::fs::write(path.join("stat"), stat).unwrap();
|
||||
}
|
||||
let gone = root.path().join("104");
|
||||
let non_pid = root.path().join("self");
|
||||
assert_eq!(
|
||||
linux_process_group_members(
|
||||
100,
|
||||
[Ok(zombie.clone()), Ok(foreign), Ok(gone), Ok(non_pid)]
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
linux_process_group_members(100, [Ok(zombie), Ok(live)]),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn incomplete_process_scan_never_proves_empty() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let pid = root.path().join("101");
|
||||
std::fs::create_dir(&pid).unwrap();
|
||||
// PID 目录仍在:缺失 stat 与进程确实消失不同。
|
||||
assert_eq!(linux_process_group_members(100, [Ok(pid.clone())]), None);
|
||||
std::fs::create_dir(pid.join("stat")).unwrap();
|
||||
assert_eq!(linux_process_group_members(100, [Ok(pid.clone())]), None);
|
||||
std::fs::remove_dir(pid.join("stat")).unwrap();
|
||||
for malformed in [
|
||||
"invalid",
|
||||
"101 (worker) S",
|
||||
"101 (worker) S 1 bad",
|
||||
"101 (worker) unknown 1 100",
|
||||
] {
|
||||
std::fs::write(pid.join("stat"), malformed).unwrap();
|
||||
assert_eq!(linux_process_group_members(100, [Ok(pid.clone())]), None);
|
||||
}
|
||||
std::fs::write(pid.join("stat"), "101 (worker) Z 1 100").unwrap();
|
||||
assert_eq!(
|
||||
linux_process_group_members(
|
||||
100,
|
||||
[
|
||||
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
|
||||
Ok(pid.clone())
|
||||
]
|
||||
),
|
||||
None
|
||||
);
|
||||
// 枚举错误不提前停止:其后有确定的活跃成员时仍直接报告存活。
|
||||
std::fs::write(pid.join("stat"), "101 (worker) R 1 100").unwrap();
|
||||
assert_eq!(
|
||||
linux_process_group_members(
|
||||
100,
|
||||
[
|
||||
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
|
||||
Ok(pid)
|
||||
]
|
||||
),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn real_zombie_is_not_a_live_group_member_before_reaping() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("30")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pgid = child.id() as i32;
|
||||
let live = linux_process_group_has_live_member(pgid);
|
||||
child.kill().unwrap();
|
||||
let stat_path = format!("/proc/{pgid}/stat");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
let mut zombie = false;
|
||||
while std::time::Instant::now() < deadline {
|
||||
zombie = std::fs::read_to_string(&stat_path).is_ok_and(|stat| {
|
||||
stat.rsplit_once(')')
|
||||
.is_some_and(|(_, fields)| fields.split_whitespace().next() == Some("Z"))
|
||||
});
|
||||
if zombie {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let group_exists = unsafe { libc::kill(-pgid, 0) } == 0;
|
||||
let after_kill = linux_process_group_has_live_member(pgid);
|
||||
// 断言前回收直属子进程,测试失败也不留下僵尸。
|
||||
child.wait().unwrap();
|
||||
assert_eq!(live, Some(true));
|
||||
assert!(zombie && group_exists);
|
||||
assert_eq!(after_kill, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_retirement_allows_a_new_turn_without_claiming_full_tree_acceptance() {
|
||||
let mut proof = ProcessTreeExitProof {
|
||||
|
||||
@@ -519,7 +519,12 @@ pub(super) async fn review_reply(
|
||||
}
|
||||
let ledger = session.snapshot()?;
|
||||
if ledger.contract.is_none() {
|
||||
session.finish_without_contract()?;
|
||||
let finished = session.finish_without_contract();
|
||||
// 收尾的预算检查可能刚写入 Exhausted;优先呈现已持久化的终态报告。
|
||||
if let Some(report) = terminal_report(session) {
|
||||
return Ok(Some(report));
|
||||
}
|
||||
finished?;
|
||||
return Ok(None);
|
||||
}
|
||||
if try_seal(root, session).await? || session.snapshot()?.phase == ExecutionPhase::Sealing {
|
||||
|
||||
@@ -1167,7 +1167,7 @@ impl ExecutionSession {
|
||||
next.phase = ExecutionPhase::Interrupted;
|
||||
let prior = next.terminal_report.take().unwrap_or_default();
|
||||
next.terminal_report = Some(format!(
|
||||
"{prior}\n执行器缺少完整子进程退出证明。本轮仍未验收,需核对后台操作结果。"
|
||||
"{prior}\n执行器缺少受控归属退出证明。本轮仍未验收,需核对后台操作结果。"
|
||||
));
|
||||
}
|
||||
self.commit(&mut data, next)
|
||||
@@ -1263,13 +1263,19 @@ impl ExecutionSession {
|
||||
self.commit(&mut data, next)
|
||||
}
|
||||
|
||||
/// 只把原因追加进终态说明,**不改阶段**。
|
||||
/// 仅追加到失败终态说明,不为正常收尾生成终态报告,也不改写完成后的回复。
|
||||
///
|
||||
/// 宿主自己收尾(正常终态、预算与交付收尾)时会先关掉 app-server,连接随之关闭;
|
||||
/// 这类「关闭原因」要留痕给排障看,但不能把已经/正在正常收口的回合改写成 `Interrupted`
|
||||
/// —— CLI、单回合宿主每轮都会命中这个窗口(见 pitfalls 2026-09-28)。
|
||||
pub(super) fn append_terminal_note(&self, reason: String) -> Result<(), String> {
|
||||
let mut data = self.lock()?;
|
||||
if !matches!(
|
||||
data.ledger.phase,
|
||||
ExecutionPhase::Interrupted | ExecutionPhase::Exhausted
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut next = data.ledger.clone();
|
||||
let prior = next.terminal_report.take().unwrap_or_default();
|
||||
next.terminal_report = Some(
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_contract_review_returns_budget_report_created_during_close() {
|
||||
let (_temp, session) = fixture_without_contract(DirectValidationConfig {
|
||||
max_turn_seconds: 1,
|
||||
..Default::default()
|
||||
});
|
||||
// 不先 tick:让 review_reply 进入无合同分支后,收尾本身触发预算终态。
|
||||
session.lock().unwrap().elapsed_offset_ms = 1001;
|
||||
assert!(session.snapshot().unwrap().terminal_report.is_none());
|
||||
let report = super::super::direct_delivery::review_reply(&session.root, &session)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("预算报告应优先于关闭错误");
|
||||
let state = session.snapshot().unwrap();
|
||||
assert_eq!(state.phase, ExecutionPhase::Exhausted);
|
||||
assert!(report.contains("时间预算已耗尽"));
|
||||
assert_eq!(state.terminal_report.as_deref(), Some(report.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_contract_review_preserves_close_error_without_terminal_report() {
|
||||
let (_temp, session) = fixture_without_contract(Default::default());
|
||||
let operation = session.admit(EffectKind::Execute, None).unwrap();
|
||||
let result = super::super::direct_delivery::review_reply(&session.root, &session).await;
|
||||
assert!(result.is_err());
|
||||
let state = session.snapshot().unwrap();
|
||||
assert_eq!(state.phase, ExecutionPhase::Working);
|
||||
assert!(state.terminal_report.is_none());
|
||||
operation.finish(true, false, None).unwrap();
|
||||
}
|
||||
|
||||
fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata {
|
||||
use crate::analytics::contract::{Context, Route, RunSource, Source};
|
||||
crate::analytics::run::Metadata::new(
|
||||
@@ -182,6 +213,16 @@ fn legacy_run_without_metadata_is_not_assigned_current_users_identity() {
|
||||
}
|
||||
|
||||
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
|
||||
let (temp, session) = fixture_without_contract(config);
|
||||
session
|
||||
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
|
||||
.unwrap();
|
||||
(temp, session)
|
||||
}
|
||||
|
||||
fn fixture_without_contract(
|
||||
config: DirectValidationConfig,
|
||||
) -> (tempfile::TempDir, Arc<ExecutionSession>) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("project");
|
||||
crate::init_local_game_project_at(&root, "execution-test", "执行测试").unwrap();
|
||||
@@ -194,12 +235,60 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.newly_accepted);
|
||||
session
|
||||
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
|
||||
.unwrap();
|
||||
(temp, session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_notes_do_not_create_or_overwrite_success_reports() {
|
||||
let (_temp, ordinary) = fixture_without_contract(Default::default());
|
||||
for closing in [false, true] {
|
||||
if closing {
|
||||
ordinary.begin_closing().unwrap();
|
||||
}
|
||||
ordinary.append_terminal_note("连接已关闭".into()).unwrap();
|
||||
assert!(ordinary.snapshot().unwrap().terminal_report.is_none());
|
||||
}
|
||||
ordinary.record_process_exit_proof(true).unwrap();
|
||||
ordinary.finish_attempt().unwrap();
|
||||
ordinary.finish_without_contract().unwrap();
|
||||
ordinary
|
||||
.append_terminal_note("迟到关闭通知".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ordinary.snapshot().unwrap().phase,
|
||||
ExecutionPhase::Completed
|
||||
);
|
||||
assert!(ordinary.snapshot().unwrap().terminal_report.is_none());
|
||||
|
||||
let (_temp, delivery) = fixture(Default::default());
|
||||
assert!(delivery
|
||||
.begin_sealing(delivery.snapshot().unwrap().revision)
|
||||
.unwrap());
|
||||
delivery.append_terminal_note("连接已关闭".into()).unwrap();
|
||||
assert!(delivery.snapshot().unwrap().terminal_report.is_none());
|
||||
delivery.record_process_exit_proof(true).unwrap();
|
||||
delivery.complete("验收完成".into()).unwrap();
|
||||
delivery
|
||||
.append_terminal_note("迟到关闭通知".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
delivery.snapshot().unwrap().terminal_report.as_deref(),
|
||||
Some("验收完成")
|
||||
);
|
||||
|
||||
let (_temp, failed) = fixture(Default::default());
|
||||
failed.interrupt("用户取消".into()).unwrap();
|
||||
failed.append_terminal_note("连接已关闭".into()).unwrap();
|
||||
assert_eq!(
|
||||
failed.snapshot().unwrap().phase,
|
||||
ExecutionPhase::Interrupted
|
||||
);
|
||||
assert_eq!(
|
||||
failed.snapshot().unwrap().terminal_report.as_deref(),
|
||||
Some("用户取消\n连接已关闭")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_failures_do_not_consume_delivery_reviews_or_block_other_operations() {
|
||||
let (_temp, session) = fixture(DirectValidationConfig {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Direct 收尾与进程证明修复实施计划
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 0.1 |
|
||||
| Status | implemented(Linux 定向回归通过;Windows 目标平台复核未运行) |
|
||||
| Date | 2026-10-05 |
|
||||
| Milestone | [Direct 收尾与进程证明修复](./【里程碑】Direct收尾与进程证明修复-2026-10-05.md) |
|
||||
|
||||
1. 两处适配器收尾用受控归属退役结果登记 executor_stopped,保留完整子树证明范围;通过实际子进程协议夹具检查最终回复和账本状态。正常关闭的迟到通知不能在 Working/Closing/Sealing 生成终态报告或改写 Completed,补充对应反例。
|
||||
2. Linux 扫描仅处理数字 PID,累积读取/解析不确定性,真实活跃成员优先;已确认消失可跳过。增加确定性目录、状态错误与真实进程回归。
|
||||
3. 无合同回复复核捕获收尾结果后读取终态报告;用测试时钟偏移触发收尾预算耗尽,同时覆盖非终态错误。
|
||||
4. 运行 process_tree、完整响应、direct_execution、direct_delivery、execution 适配器定向测试,cargo check、格式、文档索引、编码和 diff 检查。
|
||||
|
||||
优先完成三项行为及定向验证;检查点为代码完成、Linux 回归完成。风险为误认退出和遮蔽真实错误,均须负向用例。无真实模型或 Windows 环境时不宣称对应平台通过;不新增调度或视觉/玩法规则。
|
||||
|
||||
## 实施与验证结果
|
||||
|
||||
| 验收面 | 证据 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 无合同操作回合 | 实际 Unix app-server 协议子进程完成原生命令事件与回复后,复核得到 Completed、无终态报告、保留模型正文 | 通过 |
|
||||
| ready 合同 | 同一协议夹具验证操作后完整响应、进程组退役、Completed 和宿主验收报告;完整子树证明仍为 false | 通过 |
|
||||
| 关闭通知 | Working/Closing/Sealing 不产生报告,Completed 不被迟到通知改写,Interrupted 仍可追加说明 | 通过 |
|
||||
| Linux 扫描 | 僵尸、活跃与其他组、已消失 PID、目录枚举错误、不可读及格式错误 stat;真实未 wait 的僵尸仍被 kill(0) 发现但不算活跃 | 通过 |
|
||||
| 收尾预算 | 无合同回复复核内部触发预算 Exhausted,返回已持久化预算报告;有在途操作但无终态报告仍返回关闭错误 | 通过 |
|
||||
| 既有负向边界 | 未证明退出不能完成、冻结合同证据与失败/关闭准入等原有回归 | 通过 |
|
||||
|
||||
定向测试合并运行 75 passed、1 ignored(进程辅助夹具),覆盖 direct_execution、direct_delivery、execution 适配器、process_tree 和三个完整响应夹具。完整响应夹具使用本地 loopback 与实际子进程,不调用真实 LLM 或付费 Provider。
|
||||
|
||||
`cargo check --locked`、修改文件 rustfmt、文档索引、编码与 `git diff --check` 均通过。编译仍报告仓库既有告警;初次协议夹具运行因沙箱禁止 loopback 绑定而未进入行为验证,获准重跑后通过。
|
||||
|
||||
验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- agent::direct_execution::tests agent::direct_delivery::tests agent::codex_app_server::execution::tests agent::codex_app_server::process_tree::tests no_contract_operation_completes ready_contract_allows direct_project_turn_does_not_forward`。
|
||||
|
||||
Windows 目标平台尚未运行;本次 Linux 结果不替代 Windows Job 子树及客户端专项验证,复核后清理本临时计划和里程碑。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Direct 收尾与进程证明修复
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 0.1 |
|
||||
| Status | implemented(Linux 定向回归通过;Windows 目标平台复核未运行) |
|
||||
| Date | 2026-10-05 |
|
||||
| Parent Spec | [Direct 合同规则](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#宿主验收与执行许可合同) |
|
||||
|
||||
交付:正常 Unix 归属清理允许回合完成,不完整进程扫描不能证明退出,收尾预算报告不被通用错误覆盖。
|
||||
|
||||
范围仅限执行适配器、进程组观察、回复复核及对应测试;视觉/玩法、调度、预算数值及持久化格式不变。
|
||||
|
||||
验收:Linux 无合同操作回合 Completed 且无宿主报告;ready 合同 Completed 且有验收报告;扫描失败保持未知、僵尸不算活跃、活跃成员阻止退役;收尾预算耗尽返回已存报告并保持 Exhausted,其他关闭错误继续报错。Windows Job 完整证明语义不变,目标平台验证单列。
|
||||
|
||||
Linux 上述条款已通过;正常关闭的迟到通知也已覆盖,不会阻断回复复核或覆盖完成后的报告。具体结果见[实施记录](./【实施计划】Direct收尾与进程证明修复-2026-10-05.md)。剩余项为 Windows 目标平台复核,不扩大到视觉/玩法或真实付费 Provider。
|
||||
@@ -857,7 +857,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
|
||||
- Direct 回合的合同、证据和预算以宿主私有账本为准,项目侧记录只作展示。GUI/CLI 共用入口;首次副作用前冻结非空验收合同,可信新 Web 工程由宿主补充构建和双端验证底线。普通无副作用聊天不强制构建。
|
||||
- 视觉、固定玩法和托管命令分层;`validation.maxRuns` 按执行/返修批次管理,正常开发命令共享批次;累计执行时间与整轮墙钟分别受 `maxExecutionSeconds` / `maxTurnSeconds` 约束,显式配置与 Provider 重试独立。源码、构建输出、环境输入与证据文件摘要分别复核,项目可编辑记录不能抬高预算或伪造成功。
|
||||
- 原生工具使用已验证的捆绑版本逐次审批能力,第三方 MCP 显式逐调用询问;所有 Direct 入口接受宿主同一状态,未知远端结果不得以本地进程退出代替。独立客户端 HTTP MCP 使用明确的 ExternalClient 来源,保留其既有边界,不借用另一 Direct 回合的预算。
|
||||
- 交付必须先封口、排空和取得执行器退出证明,再核对当前文件并提交完成;Windows 用自有 Job 约束进程树,托管命令在恢复主线程前绑定。完整退出证明不足时保持未完成,不把模型最终回复当作验收。非阻塞扩项进入新的用户回合。
|
||||
- 交付必须先封口、排空和取得受控执行归属退出证明,再核对当前文件并提交完成;Windows 用自有 Job 约束进程树,托管命令在恢复主线程前绑定。Unix 受控进程组退役允许回合完成,但不宣称完整子树均已退出;归属清理失败仍保持未完成,不把模型最终回复当作验收。非阻塞扩项进入新的用户回合。
|
||||
- 模型配置、实际请求标识和流分段耗时写入现有审计账本;统计采用并发区间并集,有界后台写入,详细条目截断后仍聚合。上游内部排队和推理耗时不可见时保持未知。
|
||||
- Direct 工具集中 SDK 原生 `apply_patch` / `update_plan` 是全局串行单例,按回合为每个 Direct 连接导出一份只把 `apply_patch_tool_type` 置空的完整模型目录即可移除该注册;其余 metadata、匹配与 fallback 不变,不得伪造 `readOnlyHint` 或改造 SDK。等价能力由宿主 MCP 的 `agc_apply_patch`(官方 parser、当前回合 Write 许可、受控进程树、短项目事务)与 `agc_update_plan`(宿主计划状态,不作为验收证据)提供;缺少合法回包通道的原生问答工具一并关闭。
|
||||
- 捆绑 Codex 固定版本只在 `build_support/codex_bundle.rs` 声明一次(当前 0.155.1),构建期侧车清单、宿主补丁执行器身份、逐次审批协议允许列表和模型目录捕获共同引用;升级原生依赖时同步重取同一 tag 的 vendor 解析源码与 UPSTREAM 证据,并复跑真实目录、补丁往返与并发夹具。0.155 起原生执行入口改为统一 exec(`exec_command` + `write_stdin`,旧 `shell_command` 不再注册),宿主许可与预算照常覆盖。
|
||||
@@ -9629,3 +9629,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 用户确认手动 GUI 检查已完成,[PR #608](https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/608) 已移除 WIP;删除要求该 PR 维持 WIP 的过期说明。
|
||||
- 视觉与玩法检查保持现状,包括既有双端、固定场景及证据真实性校验;本次不继续重构这两类要求。
|
||||
- 手动 GUI 确认与专项平台证据分别记录;未提供的 Windows 用例明细不推定为通过。
|
||||
|
||||
## 2026-10-05:Direct 受控归属退出与终态报告
|
||||
|
||||
- 回合收尾和合同完成使用平台可证明的受控归属退役:Windows 为自有 Job,Unix 为受控进程组。进程组成功退出不再被完整子树标志误判为中断,但不宣称逃逸后代已退出;缺失或失败的清理仍阻止完成。
|
||||
- Linux 排除僵尸时必须保守处理不完整扫描;数字 PID 的读取/解析不确定性不能成为组已空的证据,已确认消失的 PID 可以忽略。
|
||||
- 正常关闭连接的迟到通知不创建或覆盖成功回合的终态报告;无合同收尾新产生的预算报告优先返回,其他真实关闭错误保留。
|
||||
- 权威边界见[Direct 合同规则](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#宿主验收与执行许可合同)。视觉/玩法判据和预算值不变。
|
||||
|
||||
@@ -188,7 +188,10 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema
|
||||
- 原生、内置与第三方所有入口都经过同一宿主状态;独立工具继续并行,只有身份、回合关闭、收尾与必要资源冲突形成短临界区。能力检测失败不得退回无控制执行。
|
||||
- 上述回合控制适用于 DirectProject。独立客户端 HTTP MCP 显式使用 ExternalClient 来源,保持其原有权限、幂等和浏览器能力,不借用当前项目另一条 Direct 回合的预算或可信证据;新交付合同与托管验证命令工具要求 Direct 会话。服务端 external_mcp 不变。
|
||||
- 自动交付验收只在模型正常结束本次响应后启动;操作结算、AGC 补丁成功、登记与状态查询都不触发封口。正常完成后证据齐备则进入 Sealing,拒绝新副作用,确认在途归零和执行器退出,再复核运行输入及证据,成功才进入 Completed 并返回宿主报告。未达标仍在原预算与复核次数内反馈修复;取消、耗尽、断连等异常终止保持其原因,不能因证据齐备改成成功。
|
||||
- Windows app-server 在任何模型工具执行前绑定不可脱离的自有 Job,超时/取消/断连时验证整个 Job 已退出。其它平台继续保留受控进程组;未取得完整子树退出证据时按不确定状态报告,不宣称全部后台执行已停止。已受理的远端付费任务保留原不确定围栏,断连不构成自动重放授权。
|
||||
- Windows app-server 在任何模型工具执行前绑定不可脱离的自有 Job,超时/取消/断连时验证整个 Job 已退出。其它平台继续保留受控进程组;受控归属退出可用于回合收尾及合同完成,但进程组证明不宣称逃逸后代或完整子树均已退出。正常操作回合在 Closing 清理后返回 Working,再由回复复核完成;已封口回合保持 Sealing 直至验收完成。缺失或失败的归属清理仍进入 Interrupted。已受理的远端付费任务保留原不确定围栏,断连不构成自动重放授权。
|
||||
- Linux 进程组检查排除僵尸;目录枚举失败或数字 PID 的状态不可读取、不可解析时,不以不完整扫描证明组为空,回退到进程组存在性检查。已确认消失的 PID 可以忽略;非进程目录不参与扫描。
|
||||
- 无合同收尾在预算检查中产生终态报告时,回复复核优先返回已持久化报告;没有终态报告的关闭失败仍保留真实错误,不把预算耗尽改成 Completed。
|
||||
- 宿主正常关闭连接产生的迟到通知仅作为诊断,不为 Working/Closing/Sealing 生成终态报告,也不改写 Completed 的回复;只有 Interrupted/Exhausted 的既有失败终态可以追加收尾说明。
|
||||
- 付费许可始终绑定原回合和原操作,不能在容量或同动作锁排队结束后借用新回合。排队可取消,每次新 POST 前与封口共用宿主状态短锁,核对原许可、期限与阶段并持久化提交边界;封口、终止或耗尽后不得新增提交。已经越过提交边界的请求不丢弃,其 operation ID 和不确定状态继续持久化并允许原 GET 对账,多阶段生成的下一次 POST 仍须重新核验。ExternalClient 与手工资源操作保持既有语义。
|
||||
- 执行账本使用 `agc-direct-execution.v3`,删除 requiresContract,保留回合预算、归属、活动操作、退出证明及合同证据。读取 v1/v2 只白名单移除退役字段,未知字段仍拒绝;旧终态不重开,含旧合同的非终态保留要求并转为 Interrupted,提示新用户回合继续,不因删除要求宣告成功。旧无合同账本移除强制标记后仍按原预算与活动操作恢复规则处理;更早项目侧验证账本缺少可信时间,不能授予同回合新预算。旧合同终态可查询历史但不再按新标准评估。
|
||||
- 模型一次执行结束时先进入 Closing,关闭新操作和迟到提交,核实自有执行器退出及活动操作归零后才能回到 Working 接受交付反馈;整个用户回合结束后撤销旧许可。Closing 只用于实际关闭,不由普通工具失败触发。普通无合同回合完成不宣称游戏验收通过。
|
||||
|
||||
Reference in New Issue
Block a user