Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs
T
kdletters bdf7ed288a 放宽直连游戏验收并增加运行时素材证据
- 将固定四切片与固定 drawImage 降为推荐路径

- 在导航前注入 Canvas/WebGL 平台素材运行时观测

- 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改

- 补充直连验收、浏览器脚本与真实 Chrome fixture 测试

- 同步 AGC 直连 Runtime 实施计划
2026-08-18 13:14:45 +08:00

1652 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub(crate) fn validate_process_session_command_spec(
spec: &ProjectCommandSpec,
) -> Result<(), String> {
const DETACH_ARGUMENTS: &[&str] = &[
"--background",
"--daemon",
"--daemonize",
"--detach",
"--fork",
];
if spec.arguments.iter().any(|argument| {
let argument = argument.trim().to_ascii_lowercase();
DETACH_ARGUMENTS.contains(&argument.as_str())
}) {
return Err("command.start 不允许 daemonize、detach、fork 或 background 参数".to_string());
}
if spec.program == "npm"
&& spec.arguments.first().map(String::as_str) == Some("run")
&& spec.arguments.len() >= 2
{
let package_path = spec.cwd.join("package.json");
let metadata = fs::metadata(&package_path)
.map_err(|error| format!("command.start 无法读取 package.json{error}"))?;
if !metadata.is_file() || metadata.len() > 256 * 1024 {
return Err("command.start package.json 必须是 256 KiB 内的普通文件".to_string());
}
let package = fs::read_to_string(&package_path)
.map_err(|error| format!("command.start 无法读取 package.json{error}"))?;
let package = serde_json::from_str::<serde_json::Value>(&package)
.map_err(|error| format!("command.start package.json JSON 无效:{error}"))?;
let script_name = &spec.arguments[1];
let script = package
.get("scripts")
.and_then(|value| value.get(script_name))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("command.start npm script 不存在:{script_name}"))?;
let normalized = script.to_ascii_lowercase();
if [
"nohup", "setsid", "disown", "start /b", "--detach", "--daemon",
]
.iter()
.any(|marker| normalized.contains(marker))
|| normalized.trim_end().ends_with('&')
{
return Err("command.start npm script 包含已知脱离 Runner 的启动方式".to_string());
}
}
Ok(())
}
pub(super) fn process_session_command_builder(
launch: &ProjectCommandLaunchSpec,
#[cfg(target_os = "linux")] bridge: &ProcessSessionBridgeServer,
) -> Result<CommandBuilder, String> {
#[cfg(windows)]
let is_npm_launch = launch
.executable
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd"))
|| launch.arguments.first().is_some_and(|argument| {
std::path::Path::new(argument)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"))
});
#[cfg(target_os = "linux")]
let mut command = {
let current_executable = std::env::current_exe()
.map_err(|error| format!("定位 process session child wrapper 失败:{error}"))?;
let mut command = CommandBuilder::new(current_executable);
#[cfg(not(test))]
{
command.arg(PROCESS_SESSION_CHILD_MODE);
}
#[cfg(test)]
{
command.args([
"--exact",
"process_session::tests::process_session_child_wrapper_fixture",
"--nocapture",
"--test-threads=1",
]);
}
command
};
#[cfg(not(target_os = "linux"))]
let mut command = {
#[cfg(windows)]
{
if launch
.executable
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd"))
{
let npm_directory = launch
.executable
.parent()
.ok_or_else(|| "command.start 无法定位 Windows npm 安装目录".to_string())?;
let node_executable = npm_directory.join("node.exe");
let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js");
if !node_executable.is_file() || !npm_cli.is_file() {
return Err(
"command.start Windows npm 安装缺少 node.exe 或 npm-cli.js".to_string()
);
}
let node_executable = node_executable.to_string_lossy();
let npm_cli = npm_cli.to_string_lossy();
let mut command = CommandBuilder::new(
node_executable
.strip_prefix(r"\\?\")
.unwrap_or(&node_executable),
);
command.arg(npm_cli.strip_prefix(r"\\?\").unwrap_or(&npm_cli));
command.args(&launch.arguments);
command
} else {
let mut command = CommandBuilder::new(&launch.executable);
command.args(&launch.arguments);
command
}
}
#[cfg(not(windows))]
{
let mut command = CommandBuilder::new(&launch.executable);
command.args(&launch.arguments);
command
}
};
#[cfg(windows)]
{
let cwd = launch.cwd.to_string_lossy();
command.cwd(cwd.strip_prefix(r"\\?\").unwrap_or(&cwd));
}
#[cfg(not(windows))]
command.cwd(&launch.cwd);
command.env_clear();
#[cfg(not(target_os = "linux"))]
for (name, value) in &launch.environment {
command.env(name, value);
}
#[cfg(windows)]
if is_npm_launch {
let node_executable = launch.executable.to_string_lossy();
let node_executable = node_executable
.strip_prefix(r"\\?\")
.unwrap_or(&node_executable);
command.env("npm_node_execpath", node_executable);
command.env("NODE", node_executable);
command.env("npm_config_node_gyp", "");
// Windows 环境变量名不区分大小写。先移除继承的拼写,避免
// CommandBuilder 更新值后仍保留 `ComSpec` 而隐藏 npm 的小写键。
command.env_remove("ComSpec");
command.env("npm_config_script_shell", r"C:\Windows\System32\cmd.exe");
}
#[cfg(target_os = "linux")]
{
command.env(
PROCESS_SESSION_OWNER_PID_ENV,
std::process::id().to_string(),
);
command.env(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV, bridge.endpoint());
command.env(PROCESS_SESSION_BRIDGE_NONCE_ENV, bridge.nonce_hex());
}
Ok(command)
}
pub(crate) fn validate_process_session_start_preflight_at(
root: &Path,
identity: &ProcessSessionIdentity,
spec: &ProjectCommandSpec,
) -> Result<(), String> {
validate_process_session_identity(identity)?;
if identity.project_id != game_creator_agent_runtime_context_project_id(root)? {
return Err("command.start projectId 与当前项目不匹配".to_string());
}
validate_process_session_command_spec(spec)?;
if find_existing_start_action_record(root, identity)?.is_some() {
return Ok(());
}
let records = active_process_session_records_at(root, None, None)?;
#[cfg(target_os = "linux")]
let pending = pending_process_launch_registry()
.lock()
.map_err(|_| "pending process launch registry 锁已损坏".to_string())?
.launches
.values()
.filter(|launch| launch.root == root)
.cloned()
.collect::<Vec<_>>();
if let Some(record) = records
.iter()
.find(|record| record.needs_reconciliation || record.status == "needs-reconciliation")
{
return Err(format!(
"项目存在待人工核对的进程会话 {},禁止启动新会话",
record.process_id
));
}
#[cfg(target_os = "linux")]
let pending_project_count = pending.len();
#[cfg(not(target_os = "linux"))]
let pending_project_count = 0;
if records.len().saturating_add(pending_project_count) >= PROCESS_SESSION_MAX_PER_PROJECT {
return Err(format!(
"当前项目最多同时运行 {PROCESS_SESSION_MAX_PER_PROJECT} 个 process session"
));
}
let agent_count = records
.iter()
.filter(|record| record.agent_id == identity.agent_id)
.count();
#[cfg(target_os = "linux")]
let agent_count = agent_count.saturating_add(
pending
.iter()
.filter(|launch| launch.agent_id == identity.agent_id)
.count(),
);
if agent_count >= PROCESS_SESSION_MAX_PER_AGENT {
return Err(format!(
"当前 Agent 最多同时运行 {PROCESS_SESSION_MAX_PER_AGENT} 个 process session"
));
}
Ok(())
}
pub(crate) fn start_process_session_at(
root: &Path,
identity: ProcessSessionIdentity,
spec: &ProjectCommandSpec,
source_fingerprint_before: String,
) -> Result<ProcessSessionPollResult, String> {
let launch =
prepare_project_command_launch_spec(root, spec).map_err(|error| error.to_string())?;
start_prepared_process_session_at(
root,
identity,
spec,
&launch,
source_fingerprint_before,
|| Ok(()),
)
.map_err(|error| error.to_string())
}
pub(crate) fn start_prepared_process_session_at<F>(
root: &Path,
identity: ProcessSessionIdentity,
spec: &ProjectCommandSpec,
launch: &ProjectCommandLaunchSpec,
source_fingerprint_before: String,
durable_commit: F,
) -> Result<ProcessSessionPollResult, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
#[cfg(target_os = "linux")]
{
start_linux_process_session_at(
root,
identity,
spec,
launch,
source_fingerprint_before,
durable_commit,
)
}
#[cfg(not(target_os = "linux"))]
{
start_legacy_process_session_at(
root,
identity,
spec,
launch,
source_fingerprint_before,
durable_commit,
)
}
}
#[cfg(target_os = "linux")]
fn start_linux_process_session_at<F>(
root: &Path,
identity: ProcessSessionIdentity,
spec: &ProjectCommandSpec,
launch: &ProjectCommandLaunchSpec,
source_fingerprint_before: String,
durable_commit: F,
) -> Result<ProcessSessionPollResult, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
validate_process_session_start_preflight_at(root, &identity, spec)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
if let Some(mut existing) = find_existing_start_action_record(root, &identity)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?
{
if matches!(
existing.status.as_str(),
"prepared" | "launching" | "running" | "terminating"
) {
if existing.owner_boot_id == process_session_boot_id()
&& live_process_session(&existing.process_id)
.map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?
.is_some()
{
return poll_process_session_at(
root,
&identity,
&existing.process_id,
None,
Some(0),
Some(0),
)
.map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
});
}
reconcile_stale_active_process_session(&mut existing);
write_process_session_record(root, &existing).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::AuditLog,
format!("command.start 旧启动状态无法写入 reconciliation{error}"),
)
})?;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
"command.start 已提交启动但缺少当前 Runner 句柄,禁止自动重放",
));
}
return poll_process_session_at(
root,
&identity,
&existing.process_id,
None,
Some(0),
Some(0),
)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error));
}
let process_id = process_session_id(&identity);
let command_id = format!(
"cmd-{}",
&format!(
"{:x}",
Sha256::digest(
serde_json::to_vec(&serde_json::json!({
"program": spec.program,
"args": spec.arguments,
"cwd": spec.cwd_relative,
}))
.unwrap_or_default()
)
)[..24]
);
let _pending_launch = reserve_pending_process_launch(root, &identity.agent_id, &process_id)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let bridge_server = ProcessSessionBridgeServer::bind()
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let pair = native_pty_system()
.openpty(PtySize {
rows: 30,
cols: 120,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("创建 command.start PTY 失败:{error}"),
)
})?;
let reader = pair.master.try_clone_reader().map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("克隆 command.start PTY reader 失败:{error}"),
)
})?;
let writer = pair.master.take_writer().map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("取得 command.start PTY writer 失败:{error}"),
)
})?;
let command = process_session_command_builder(launch, &bridge_server)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let mut child = pair.slave.spawn_command(command).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Spawn,
format!("启动 command.start wrapper 失败:{error}"),
)
})?;
drop(pair.slave);
let process_group_leader = pair.master.process_group_leader().or_else(|| {
child
.process_id()
.and_then(|value| i32::try_from(value).ok())
});
let peer_pid = child.process_id().ok_or_else(|| {
let termination = terminate_pending_process_session_child(&mut child, process_group_leader);
project_command_error_after_pending_termination(
ProjectCommandErrorStage::Preflight,
"command.start wrapper 缺少 pid",
termination,
)
})?;
let process_group_leader = Some(
process_group_leader
.or_else(|| i32::try_from(peer_pid).ok())
.ok_or_else(|| {
let termination = terminate_pending_process_session_child(&mut child, None);
project_command_error_after_pending_termination(
ProjectCommandErrorStage::Preflight,
"command.start wrapper 缺少进程组身份",
termination,
)
})?,
);
activate_pending_process_launch(
&process_id,
process_group_leader.expect("process group leader validated"),
)
.map_err(|error| {
let termination = terminate_pending_process_session_child(&mut child, process_group_leader);
project_command_error_after_pending_termination(
ProjectCommandErrorStage::Preflight,
error,
termination,
)
})?;
let mut bridge = match bridge_server.accept(peer_pid, Duration::from_secs(3)) {
Ok(bridge) => bridge,
Err(error) => {
let termination =
terminate_pending_process_session_child(&mut child, process_group_leader);
return Err(project_command_error_after_pending_termination(
ProjectCommandErrorStage::Preflight,
error,
termination,
));
}
};
if let Err(error) = bridge.send_prepare(launch, spec) {
let termination = terminate_pending_process_session_child(&mut child, process_group_leader);
return Err(project_command_error_after_pending_termination(
ProjectCommandErrorStage::Preflight,
error,
termination,
));
}
match bridge.wait_sandbox_ready(Duration::from_secs(4)) {
Ok(ProcessSessionSandboxReadyVerdict::Ready) => {}
Ok(ProcessSessionSandboxReadyVerdict::Failed { failure_kind }) => {
let termination =
terminate_pending_process_session_child(&mut child, process_group_leader);
return Err(project_command_error_after_pending_termination(
ProjectCommandErrorStage::Preflight,
format!("command.start sandbox ready 前失败:{failure_kind}"),
termination,
));
}
Err(error) => {
let termination =
terminate_pending_process_session_child(&mut child, process_group_leader);
return Err(project_command_error_after_pending_termination(
ProjectCommandErrorStage::Preflight,
error,
termination,
));
}
}
let sandbox_ready_at = unix_timestamp();
let mut durable_record = initial_process_session_record(
&identity,
&process_id,
&command_id,
spec,
Some(launch),
&source_fingerprint_before,
"launching",
);
durable_record.sandbox_establishment = "established".to_string();
durable_record.target_exec = "not-attempted".to_string();
durable_record.started_at = sandbox_ready_at;
durable_record.sandbox_ready_at = Some(sandbox_ready_at);
if let Err(error) = durable_commit() {
let _ = bridge.abort_launch();
let termination = terminate_pending_process_session_child(&mut child, process_group_leader);
return Err(project_command_error_after_pending_termination(
ProjectCommandErrorStage::DurableCommit,
error,
termination,
));
}
if let Err(error) = write_process_session_record(root, &durable_record) {
let _ = bridge.abort_launch();
let termination = terminate_pending_process_session_child(&mut child, process_group_leader);
return Err(project_command_error_after_pending_termination(
ProjectCommandErrorStage::DurableCommit,
format!("写入 command.start commit record 失败:{error}"),
termination,
));
}
if let Err(error) = bridge.commit_exec() {
let termination = terminate_pending_process_session_child(&mut child, process_group_leader);
persist_process_session_launch_unknown(root, &mut durable_record);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
format!("{error}{termination}"),
));
}
let exec = bridge.wait_exec(Duration::from_secs(4));
match exec {
Ok(ProcessSessionExecVerdict::TargetExecFailed { errno }) => {
let termination =
terminate_pending_process_session_child(&mut child, process_group_leader);
let needs_reconciliation = !termination.confirmed;
mark_process_session_launch_record(
&mut durable_record,
if needs_reconciliation {
"needs-reconciliation"
} else {
"failed"
},
"failed",
Some("target-exec-failed"),
needs_reconciliation,
);
write_process_session_record(root, &durable_record).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::AuditLog,
format!(
"command.start target exec 失败后终态无法落盘:errno={errno}{termination}{error}"
),
)
})?;
if needs_reconciliation {
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!(
"command.start target exec 失败但 wrapper 回收无法确认:errno={errno}{termination}"
),
));
}
return poll_process_session_at(root, &identity, &process_id, None, Some(0), Some(0))
.map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error)
});
}
Ok(ProcessSessionExecVerdict::LaunchUnknown) => {
let termination =
terminate_pending_process_session_child(&mut child, process_group_leader);
persist_process_session_launch_unknown(root, &mut durable_record);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
format!("process session wrapper 报告 launch unknown{termination}"),
));
}
Err(error) => {
let termination =
terminate_pending_process_session_child(&mut child, process_group_leader);
persist_process_session_launch_unknown(root, &mut durable_record);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
format!("{error}{termination}"),
));
}
Ok(ProcessSessionExecVerdict::Established) => {}
}
let exec_established_at = unix_timestamp();
durable_record.status = "running".to_string();
durable_record.target_exec = "established".to_string();
durable_record.exec_established_at = Some(exec_established_at);
durable_record.stdin_open = true;
durable_record.updated_at = exec_established_at;
if let Err(error) = write_process_session_record(root, &durable_record) {
let termination = terminate_pending_process_session_child(&mut child, process_group_leader);
persist_process_session_launch_unknown(root, &mut durable_record);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("command.start exec-established 状态无法落盘:{error}{termination}"),
));
}
let (control_tx, control_rx) = std::sync::mpsc::channel();
let live = Arc::new(LiveProcessSession {
root: root.to_path_buf(),
identity,
process_id: process_id.clone(),
command_id,
program: spec.program.clone(),
cwd: spec.cwd_relative.clone(),
sandbox_backend: launch.sandbox_backend.clone(),
sandbox_mode: launch.sandbox_mode.clone(),
network_access: launch.network_access.clone(),
sandbox_profile_version: launch.sandbox_profile_version.clone(),
sandbox_establishment: "established".to_string(),
target_exec: "established".to_string(),
sandbox_ready_at: Some(sandbox_ready_at),
exec_established_at: Some(exec_established_at),
source_fingerprint_before,
started_at: durable_record.started_at,
output: Mutex::new(ProcessOutputState::running()),
output_changed: Condvar::new(),
writer: Mutex::new(Some(writer)),
master: Mutex::new(Some(pair.master)),
control: control_tx,
});
process_session_registry()
.lock()
.map_err(|_| {
let termination =
terminate_pending_process_session_child(&mut child, process_group_leader);
persist_process_session_launch_unknown(root, &mut durable_record);
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("process session registry 锁已损坏;{termination}"),
)
})?
.sessions
.insert(process_id.clone(), Arc::clone(&live));
let reader_live = Arc::clone(&live);
thread::spawn(move || drain_process_session_output(reader_live, reader));
let supervisor_live = Arc::clone(&live);
let timeout_seconds = spec.timeout_seconds;
thread::spawn(move || {
supervise_process_session(
supervisor_live,
&mut child,
control_rx,
timeout_seconds,
process_group_leader,
bridge,
)
});
poll_process_session_at(root, &live.identity, &process_id, None, Some(0), Some(0))
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))
}
#[cfg(target_os = "linux")]
struct PendingProcessTermination {
confirmed: bool,
summary: String,
}
#[cfg(target_os = "linux")]
impl std::fmt::Display for PendingProcessTermination {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.summary)
}
}
#[cfg(target_os = "linux")]
fn terminate_pending_process_session_child(
child: &mut Box<dyn Child + Send + Sync>,
process_group_leader: Option<i32>,
) -> PendingProcessTermination {
let group = process_group_leader.filter(|value| *value > 0);
let group_result = group.map(|group| {
let result = unsafe { libc::kill(-group, libc::SIGKILL) };
if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
});
let _ = child.kill();
let wait = child.wait();
let (confirmed, summary) = match (group_result, wait) {
(Some(Ok(())), Ok(_)) => (true, "wrapper 进程组已终止并回收".to_string()),
(None, Ok(_)) => (false, "wrapper 主进程已回收但缺少进程组身份".to_string()),
(Some(Err(error)), Ok(_)) => (
false,
format!("wrapper 主进程已回收但进程组终止失败:{error}"),
),
(_, Err(error)) => (false, format!("wrapper 进程回收失败:{error}")),
};
PendingProcessTermination { confirmed, summary }
}
#[cfg(target_os = "linux")]
fn project_command_error_after_pending_termination(
confirmed_stage: ProjectCommandErrorStage,
message: impl Into<String>,
termination: PendingProcessTermination,
) -> ProjectCommandError {
let stage = if termination.confirmed {
confirmed_stage
} else {
ProjectCommandErrorStage::LaunchUnknown
};
ProjectCommandError::new(stage, format!("{}{termination}", message.into()))
}
#[cfg(target_os = "linux")]
fn mark_process_session_launch_record(
record: &mut ProcessSessionRecord,
status: &str,
target_exec: &str,
launch_failure_kind: Option<&str>,
needs_reconciliation: bool,
) {
record.status = status.to_string();
record.target_exec = target_exec.to_string();
record.launch_failure_kind = launch_failure_kind.map(str::to_string);
record.stdin_open = false;
record.needs_reconciliation = needs_reconciliation;
record.terminal_at = Some(unix_timestamp());
record.updated_at = unix_timestamp();
}
#[cfg(target_os = "linux")]
fn persist_process_session_launch_unknown(root: &Path, record: &mut ProcessSessionRecord) {
mark_process_session_launch_record(
record,
"needs-reconciliation",
"unknown",
Some("launch-unknown"),
true,
);
let _ = write_process_session_record(root, record);
}
#[cfg(not(target_os = "linux"))]
fn start_legacy_process_session_at<F>(
root: &Path,
identity: ProcessSessionIdentity,
spec: &ProjectCommandSpec,
launch: &ProjectCommandLaunchSpec,
source_fingerprint_before: String,
durable_commit: F,
) -> Result<ProcessSessionPollResult, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
validate_process_session_start_preflight_at(root, &identity, spec)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
if let Some(existing) = find_existing_start_action_record(root, &identity)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?
{
if matches!(
existing.status.as_str(),
"prepared" | "launching" | "running" | "terminating"
) {
if existing.owner_boot_id == process_session_boot_id()
&& live_process_session(&existing.process_id)
.map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?
.is_some()
{
return poll_process_session_at(
root,
&identity,
&existing.process_id,
None,
Some(0),
Some(0),
)
.map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
});
}
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
"command.start 已进入可能启动阶段但缺少当前 Runner 句柄,禁止自动重放",
));
}
return poll_process_session_at(
root,
&identity,
&existing.process_id,
None,
Some(0),
Some(0),
)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error));
}
durable_commit().map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::DurableCommit, error)
})?;
let process_id = process_session_id(&identity);
let command_id = format!(
"cmd-{}",
&format!(
"{:x}",
Sha256::digest(
serde_json::to_vec(&serde_json::json!({
"program": spec.program,
"args": spec.arguments,
"cwd": spec.cwd_relative,
}))
.unwrap_or_default()
)
)[..24]
);
let mut durable_record = initial_process_session_record(
&identity,
&process_id,
&command_id,
spec,
Some(launch),
&source_fingerprint_before,
"prepared",
);
write_process_session_record(root, &durable_record)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?;
durable_record.status = "launching".to_string();
durable_record.updated_at = unix_timestamp();
write_process_session_record(root, &durable_record)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?;
let pair = native_pty_system()
.openpty(PtySize {
rows: 30,
cols: 120,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|error| {
process_session_launch_failed(
root,
&mut durable_record,
format!("创建 command.start PTY 失败:{error}"),
)
})
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
let command = process_session_command_builder(launch)
.map_err(|error| process_session_launch_failed(root, &mut durable_record, error))
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
let mut child = pair
.slave
.spawn_command(command)
.map_err(|error| {
process_session_launch_failed(
root,
&mut durable_record,
format!("启动 command.start {} 失败:{error}", spec.program),
)
})
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|error| {
let _ = child.kill();
let _ = child.wait();
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
process_session_launch_failed(
root,
&mut durable_record,
format!("克隆 command.start PTY reader 失败:{error}"),
),
)
})?;
let writer = pair.master.take_writer().map_err(|error| {
let _ = child.kill();
let _ = child.wait();
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
process_session_launch_failed(
root,
&mut durable_record,
format!("取得 command.start PTY writer 失败:{error}"),
),
)
})?;
#[cfg(windows)]
let windows_job = match WindowsProcessJob::assign(child.as_ref()) {
Ok(job) => job,
Err(error) => {
let _ = child.kill();
let _ = child.wait();
durable_record.status = "needs-reconciliation".to_string();
durable_record.sandbox_establishment = "unknown".to_string();
durable_record.target_exec = "unknown".to_string();
durable_record.launch_failure_kind = Some("launch-unknown".to_string());
durable_record.needs_reconciliation = true;
durable_record.terminal_at = Some(unix_timestamp());
durable_record.updated_at = unix_timestamp();
let _ = write_process_session_record(root, &durable_record);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
format!("command.start 已创建进程但无法纳入 Windows Job Object{error}"),
));
}
};
#[cfg(unix)]
let process_group_leader = pair.master.process_group_leader().or_else(|| {
child
.process_id()
.and_then(|value| i32::try_from(value).ok())
});
#[cfg(not(unix))]
let process_group_leader: Option<i32> = None;
let (control_tx, control_rx) = std::sync::mpsc::channel();
let launch_established_at = unix_timestamp();
let live = Arc::new(LiveProcessSession {
root: root.to_path_buf(),
identity,
process_id: process_id.clone(),
command_id,
program: spec.program.clone(),
cwd: spec.cwd_relative.clone(),
sandbox_backend: launch.sandbox_backend.clone(),
sandbox_mode: launch.sandbox_mode.clone(),
network_access: launch.network_access.clone(),
sandbox_profile_version: launch.sandbox_profile_version.clone(),
sandbox_establishment: "established".to_string(),
target_exec: "established".to_string(),
sandbox_ready_at: Some(launch_established_at),
exec_established_at: Some(launch_established_at),
source_fingerprint_before,
started_at: launch_established_at,
output: Mutex::new(ProcessOutputState::running()),
output_changed: Condvar::new(),
writer: Mutex::new(Some(writer)),
master: Mutex::new(Some(pair.master)),
#[cfg(windows)]
job: Mutex::new(Some(windows_job)),
control: control_tx,
});
let record = {
let output = live.output.lock().map_err(|_| {
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
"process session output 锁已损坏",
)
})?;
process_session_record_from_live(&live, &output)
};
if let Err(error) = write_process_session_record(root, &record) {
let _ = child.kill();
let _ = child.wait();
durable_record.status = "needs-reconciliation".to_string();
durable_record.sandbox_establishment = "unknown".to_string();
durable_record.target_exec = "unknown".to_string();
durable_record.launch_failure_kind = Some("launch-unknown".to_string());
durable_record.needs_reconciliation = true;
durable_record.terminal_at = Some(unix_timestamp());
durable_record.updated_at = unix_timestamp();
let _ = write_process_session_record(root, &durable_record);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
format!("command.start 已启动但 running 状态无法落盘,需要人工核对:{error}"),
));
}
process_session_registry()
.lock()
.map_err(|_| {
ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
"process session registry 锁已损坏",
)
})?
.sessions
.insert(process_id.clone(), Arc::clone(&live));
let reader_live = Arc::clone(&live);
thread::spawn(move || drain_process_session_output(reader_live, reader));
let supervisor_live = Arc::clone(&live);
let timeout_seconds = spec.timeout_seconds;
thread::spawn(move || {
supervise_process_session(
supervisor_live,
&mut child,
control_rx,
timeout_seconds,
process_group_leader,
)
});
poll_process_session_at(root, &live.identity, &process_id, None, Some(0), Some(0))
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))
}
fn append_process_output_line(live: &LiveProcessSession, line: &[u8]) -> bool {
let text = String::from_utf8_lossy(line);
let mut sanitized = redact_agent_runtime_project_paths(
&live.root,
&sanitize_project_verification_output(&text),
PROCESS_SESSION_MAX_PENDING_LINE_BYTES,
);
if matches!(line.last(), Some(b'\n' | b'\r')) && !sanitized.ends_with('\n') {
sanitized.push('\n');
}
let mut output = match live.output.lock() {
Ok(output) => output,
Err(_) => return false,
};
if output.text.len().saturating_add(sanitized.len()) > PROCESS_SESSION_MAX_OUTPUT_BYTES {
output.output_limit_exceeded = true;
output.status = "output-limit-exceeded".to_string();
output.stdin_open = false;
live.output_changed.notify_all();
return false;
}
output.text.push_str(&sanitized);
live.output_changed.notify_all();
drop(output);
if persist_live_process_snapshot(live).is_err() {
if let Ok(mut output) = live.output.lock() {
output.status = "needs-reconciliation".to_string();
output.needs_reconciliation = true;
output.stdin_open = false;
live.output_changed.notify_all();
}
let _ = live.control.send(ProcessControl::Terminate);
}
true
}
fn persist_live_process_snapshot(live: &LiveProcessSession) -> Result<(), String> {
let output = live
.output
.lock()
.map_err(|_| "process session output 锁已损坏".to_string())?;
let output_sha256 = format!("{:x}", Sha256::digest(output.text.as_bytes()));
let transcript = ProcessSessionTranscript {
schema_version: PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION.to_string(),
project_id: live.identity.project_id.clone(),
agent_id: live.identity.agent_id.clone(),
task_id: live.identity.task_id.clone(),
conversation_session_id: live.identity.conversation_session_id.clone(),
run_id: live.identity.run_id.clone(),
start_action_id: live.identity.start_action_id.clone(),
start_action_fingerprint: live.identity.start_action_fingerprint.clone(),
process_id: live.process_id.clone(),
output: output.text.clone(),
output_sha256,
output_bytes: output.text.len(),
updated_at: unix_timestamp(),
};
let record = process_session_record_from_live(live, &output);
write_agent_runtime_json_sidecar_with_max_bytes(
&live.root,
&process_session_transcript_relative_path(&live.process_id),
"Agent Runtime process transcript",
&transcript,
PROCESS_SESSION_TRANSCRIPT_MAX_BYTES,
)?;
write_process_session_record(&live.root, &record)
}
#[derive(Default)]
pub(super) struct AnsiStripper {
state: u8,
}
impl AnsiStripper {
pub(super) fn push(&mut self, byte: u8, visible: &mut Vec<u8>) {
match self.state {
0 if byte == 0x1b => self.state = 1,
0 if byte == b'\n' || byte == b'\r' || byte == b'\t' || byte >= 0x20 => {
visible.push(byte)
}
1 if byte == b'[' => self.state = 2,
1 if matches!(byte, b']' | b'P' | b'X' | b'^' | b'_') => self.state = 3,
1 => self.state = 0,
2 if (0x40..=0x7e).contains(&byte) => self.state = 0,
2 => {}
3 if byte == 0x07 => self.state = 0,
3 if byte == 0x1b => self.state = 4,
3 => {}
4 if byte == b'\\' => self.state = 0,
4 if byte == 0x1b => {}
4 => self.state = 3,
_ => self.state = 0,
}
}
}
#[cfg(windows)]
#[derive(Default)]
pub(super) struct AnsiTerminalRepositionDetector {
state: u8,
}
#[cfg(windows)]
impl AnsiTerminalRepositionDetector {
pub(super) fn push(&mut self, byte: u8) -> bool {
match self.state {
0 if byte == 0x1b => self.state = 1,
1 if byte == b'[' => self.state = 2,
1 => self.state = 0,
2 if (0x40..=0x7e).contains(&byte) => {
self.state = 0;
return matches!(byte, b'A'..=b'H' | b'f');
}
2 => {}
_ => self.state = 0,
}
false
}
}
fn drain_process_session_output(
live: Arc<LiveProcessSession>,
mut reader: Box<dyn std::io::Read + Send>,
) {
let mut buffer = [0u8; 4096];
let mut pending = Vec::new();
let mut pending_logical_line_bytes = 0usize;
let mut ansi = AnsiStripper::default();
let mut output_limit = false;
#[cfg(windows)]
let mut conpty_cursor_query_match = 0usize;
#[cfg(windows)]
let mut conpty_cursor_replied = false;
#[cfg(windows)]
let mut terminal_reposition = AnsiTerminalRepositionDetector::default();
#[cfg(windows)]
let mut conpty_soft_wrap = false;
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
for byte in &buffer[..read] {
#[cfg(windows)]
{
const CONPTY_CURSOR_QUERY: &[u8] = b"\x1b[6n";
if !conpty_cursor_replied
&& *byte == CONPTY_CURSOR_QUERY[conpty_cursor_query_match]
{
conpty_cursor_query_match += 1;
if conpty_cursor_query_match == CONPTY_CURSOR_QUERY.len() {
conpty_cursor_query_match = 0;
let reply_result = live
.writer
.lock()
.map_err(|_| "process session stdin 锁已损坏".to_string())
.and_then(|mut writer| {
let Some(writer) = writer.as_mut() else {
// 终止线程会先关闭 stdin;此时 ConPTY 可能仍把启动期
// 光标查询交给 reader。进程树已经进入收束阶段,无需再
// 把无法回复查询升级成 needs-reconciliation。
return Ok(());
};
writer
.write_all(b"\x1b[1;1R")
.and_then(|()| writer.flush())
.map_err(|error| {
format!("回复 Windows ConPTY 光标查询失败:{error}")
})
});
if let Err(error) = reply_result {
if let Ok(mut output) = live.output.lock() {
output.status = "failed".to_string();
output.needs_reconciliation = true;
output.stdin_open = false;
let detail = format!(
"\n<process output handshake failed: {error}>\n"
);
if output.text.len().saturating_add(detail.len())
<= PROCESS_SESSION_MAX_OUTPUT_BYTES
{
output.text.push_str(&detail);
}
live.output_changed.notify_all();
}
let _ = live.control.send(ProcessControl::Terminate);
return;
}
conpty_cursor_replied = true;
}
} else if !conpty_cursor_replied {
conpty_cursor_query_match =
usize::from(*byte == CONPTY_CURSOR_QUERY[0]);
}
}
#[cfg(windows)]
let ends_terminal_reposition = terminal_reposition.push(*byte);
let before = pending.len();
ansi.push(*byte, &mut pending);
let visible_bytes = pending.len().saturating_sub(before);
if visible_bytes > 0 && !matches!(pending.last(), Some(b'\n' | b'\r')) {
pending_logical_line_bytes =
pending_logical_line_bytes.saturating_add(visible_bytes);
if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES {
output_limit = true;
break;
}
}
#[cfg(windows)]
if ends_terminal_reposition && !pending.is_empty() {
pending.push(b'\n');
if !append_process_output_line(&live, &pending) {
output_limit = true;
break;
}
pending.clear();
continue;
}
if pending.len() == before {
continue;
}
if matches!(pending.last(), Some(b'\n' | b'\r')) {
if !append_process_output_line(&live, &pending) {
output_limit = true;
break;
}
#[cfg(windows)]
{
// ConPTY materializes an automatic terminal-width wrap as CR/LF.
// It is a display boundary, not an application line terminator, so
// it must not reset the logical-line safety limit. A real short line
// still resets at CR; the immediately following LF preserves that
// decision.
const PROCESS_SESSION_PTY_COLS: usize = 120;
match pending.last() {
Some(b'\r') => {
conpty_soft_wrap =
pending_logical_line_bytes >= PROCESS_SESSION_PTY_COLS;
if !conpty_soft_wrap {
pending_logical_line_bytes = 0;
}
}
Some(b'\n') => {
if !conpty_soft_wrap {
pending_logical_line_bytes = 0;
}
conpty_soft_wrap = false;
}
_ => {}
}
}
#[cfg(not(windows))]
{
pending_logical_line_bytes = 0;
}
pending.clear();
}
}
if output_limit {
if let Ok(mut output) = live.output.lock() {
output.output_limit_exceeded = true;
output.status = "output-limit-exceeded".to_string();
output.stdin_open = false;
live.output_changed.notify_all();
}
let _ = live.control.send(ProcessControl::OutputLimit);
break;
}
}
Err(error) => {
if let Ok(mut output) = live.output.lock() {
output.status = "failed".to_string();
output.needs_reconciliation = true;
output.stdin_open = false;
let detail = format!("\n<process output read failed: {error}>\n");
if output.text.len().saturating_add(detail.len())
<= PROCESS_SESSION_MAX_OUTPUT_BYTES
{
output.text.push_str(&detail);
}
live.output_changed.notify_all();
}
let _ = live.control.send(ProcessControl::Terminate);
break;
}
}
}
if !pending.is_empty() && !output_limit {
let _ = append_process_output_line(&live, &pending);
}
if let Ok(mut output) = live.output.lock() {
output.reader_finished = true;
live.output_changed.notify_all();
}
}
fn supervise_process_session(
live: Arc<LiveProcessSession>,
child: &mut Box<dyn Child + Send + Sync>,
control_rx: std::sync::mpsc::Receiver<ProcessControl>,
timeout_seconds: u64,
#[cfg_attr(not(unix), allow(unused_variables))] process_group_leader: Option<i32>,
#[cfg(target_os = "linux")] mut launch_bridge: ProcessSessionBridge,
) {
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_seconds);
let (terminal_status, exit_code, signal) = loop {
match child.try_wait() {
Ok(Some(status)) => {
#[cfg(target_os = "linux")]
let _ = &status;
#[cfg(target_os = "linux")]
let terminal = match launch_bridge.wait_terminal(Duration::from_secs(2)) {
Ok(ProcessSessionTerminalVerdict::Exited { code }) => {
("exited".to_string(), Some(code), None)
}
Ok(ProcessSessionTerminalVerdict::Signaled { signal }) => {
("exited".to_string(), None, Some(format!("signal-{signal}")))
}
Ok(ProcessSessionTerminalVerdict::Unknown) => {
let error = "process session target terminal 无法确认".to_string();
mark_process_session_reconciliation(&live, &error);
("needs-reconciliation".to_string(), None, Some(error))
}
Err(error) => {
mark_process_session_reconciliation(&live, &error);
("needs-reconciliation".to_string(), None, Some(error))
}
};
if let Err(error) = terminate_process_session_child(
&live,
child,
process_group_leader,
#[cfg(target_os = "linux")]
&mut launch_bridge,
true,
) {
mark_process_session_reconciliation(&live, &error);
break ("needs-reconciliation".to_string(), None, Some(error));
}
#[cfg(target_os = "linux")]
break terminal;
#[cfg(not(target_os = "linux"))]
break (
"exited".to_string(),
i32::try_from(status.exit_code()).ok(),
status.signal().map(str::to_string),
);
}
Ok(None) => {}
Err(error) => {
let wait_error = format!("wait failed: {error}");
if let Err(termination_error) = terminate_process_session_child(
&live,
child,
process_group_leader,
#[cfg(target_os = "linux")]
&mut launch_bridge,
true,
) {
let detail = format!("{wait_error}; {termination_error}");
mark_process_session_reconciliation(&live, &detail);
break ("needs-reconciliation".to_string(), None, Some(detail));
}
break ("failed".to_string(), None, Some(wait_error));
}
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let wait = remaining.min(Duration::from_millis(50));
match control_rx.recv_timeout(wait) {
Ok(ProcessControl::Terminate) => {
match terminate_process_session_child(
&live,
child,
process_group_leader,
#[cfg(target_os = "linux")]
&mut launch_bridge,
false,
) {
Ok(()) => break ("terminated".to_string(), None, None),
Err(error) => {
mark_process_session_reconciliation(&live, &error);
break ("needs-reconciliation".to_string(), None, Some(error));
}
}
}
Ok(ProcessControl::OutputLimit) => {
match terminate_process_session_child(
&live,
child,
process_group_leader,
#[cfg(target_os = "linux")]
&mut launch_bridge,
true,
) {
Ok(()) => break ("output-limit-exceeded".to_string(), None, None),
Err(error) => {
mark_process_session_reconciliation(&live, &error);
break ("needs-reconciliation".to_string(), None, Some(error));
}
}
}
Ok(ProcessControl::Shutdown) => {
match terminate_process_session_child(
&live,
child,
process_group_leader,
#[cfg(target_os = "linux")]
&mut launch_bridge,
true,
) {
Ok(()) => {
break (
"terminated".to_string(),
None,
Some("runner-shutdown".to_string()),
);
}
Err(error) => {
mark_process_session_reconciliation(&live, &error);
break ("needs-reconciliation".to_string(), None, Some(error));
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
match terminate_process_session_child(
&live,
child,
process_group_leader,
#[cfg(target_os = "linux")]
&mut launch_bridge,
true,
) {
Ok(()) => {
break (
"terminated".to_string(),
None,
Some("control-disconnected".to_string()),
);
}
Err(error) => {
mark_process_session_reconciliation(&live, &error);
break ("needs-reconciliation".to_string(), None, Some(error));
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
if std::time::Instant::now() >= deadline {
match terminate_process_session_child(
&live,
child,
process_group_leader,
#[cfg(target_os = "linux")]
&mut launch_bridge,
true,
) {
Ok(()) => break ("timed-out".to_string(), None, None),
Err(error) => {
mark_process_session_reconciliation(&live, &error);
break ("needs-reconciliation".to_string(), None, Some(error));
}
}
}
};
finalize_live_process_session(&live, &terminal_status, exit_code, signal);
}
pub(super) fn mark_process_session_reconciliation(live: &LiveProcessSession, error: &str) {
if let Ok(mut output) = live.output.lock() {
output.status = "needs-reconciliation".to_string();
output.needs_reconciliation = true;
output.stdin_open = false;
output.signal = Some(redact_agent_runtime_project_paths(&live.root, error, 300));
live.output_changed.notify_all();
}
}
fn terminate_process_session_child(
live: &LiveProcessSession,
child: &mut Box<dyn Child + Send + Sync>,
#[cfg_attr(not(unix), allow(unused_variables))] process_group_leader: Option<i32>,
#[cfg(target_os = "linux")] launch_bridge: &mut ProcessSessionBridge,
#[cfg_attr(windows, allow(unused_variables))] force: bool,
) -> Result<(), String> {
#[cfg(not(windows))]
let _ = live;
let mut child_reaped = child.try_wait().ok().flatten().is_some();
let tree_contained;
#[cfg(windows)]
{
tree_contained = live
.job
.lock()
.map_err(|_| "Windows process session Job Object 锁已损坏".to_string())?
.as_ref()
.ok_or_else(|| "Windows process session 缺少 Job Object".to_string())?
.terminate()
.is_ok();
}
#[cfg(unix)]
{
tree_contained = if let Some(group) = process_group_leader.filter(|value| *value > 0) {
#[cfg(target_os = "linux")]
if !force && !child_reaped {
if let Err(error) = launch_bridge.terminate_target() {
match child.try_wait() {
Ok(Some(_)) => child_reaped = true,
Ok(None) => return Err(error),
Err(wait_error) => {
return Err(format!("{error};检查 wrapper 终态失败:{wait_error}"));
}
}
}
}
#[cfg(all(unix, not(target_os = "linux")))]
if !force && !child_reaped {
unsafe {
libc::kill(-group, libc::SIGTERM);
}
}
if !force && !child_reaped {
let deadline = std::time::Instant::now()
+ Duration::from_millis(PROCESS_SESSION_TERMINATE_GRACE_MS);
while std::time::Instant::now() < deadline {
if !child_reaped {
if let Ok(Some(_)) = child.try_wait() {
child_reaped = true;
break;
}
}
thread::sleep(Duration::from_millis(25));
}
}
let killed = unsafe { libc::kill(-group, libc::SIGKILL) };
if killed == 0 {
true
} else {
std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) && child_reaped
}
} else {
false
};
}
#[cfg(not(any(unix, windows)))]
{
tree_contained = false;
}
if !child_reaped {
let _ = child.kill();
match child.wait() {
Ok(_) => child_reaped = true,
Err(error) => {
return Err(format!("process session child 回收失败:{error}"));
}
}
}
if !tree_contained {
return Err("process session 进程树终止结果无法确认".to_string());
}
if child_reaped {
Ok(())
} else {
Err("process session child 尚未回收".to_string())
}
}
fn finalize_live_process_session(
live: &Arc<LiveProcessSession>,
status: &str,
exit_code: Option<i32>,
signal: Option<String>,
) {
if let Ok(mut writer) = live.writer.lock() {
writer.take();
}
if let Ok(mut master) = live.master.lock() {
master.take();
}
#[cfg(windows)]
if let Ok(mut job) = live.job.lock() {
job.take();
}
let source_fingerprint_after = project_command_source_fingerprint(&live.root).ok();
let mut output = match live.output.lock() {
Ok(output) => output,
Err(_) => return,
};
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while !output.reader_finished && std::time::Instant::now() < deadline {
let wait = live
.output_changed
.wait_timeout(output, Duration::from_millis(25));
let Ok((next, _)) = wait else {
return;
};
output = next;
}
output.status = if output.needs_reconciliation {
"needs-reconciliation".to_string()
} else if output.output_limit_exceeded {
"output-limit-exceeded".to_string()
} else if output.status == "failed" {
"failed".to_string()
} else {
status.to_string()
};
output.exit_code = exit_code;
output.signal = signal;
output.stdin_open = false;
output.source_changed = source_fingerprint_after
.as_ref()
.map(|after| after != &live.source_fingerprint_before);
output.source_fingerprint_after = source_fingerprint_after;
if output.source_fingerprint_after.is_none() || !output.reader_finished {
output.needs_reconciliation = true;
}
let output_sha256 = format!("{:x}", Sha256::digest(output.text.as_bytes()));
let transcript = ProcessSessionTranscript {
schema_version: PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION.to_string(),
project_id: live.identity.project_id.clone(),
agent_id: live.identity.agent_id.clone(),
task_id: live.identity.task_id.clone(),
conversation_session_id: live.identity.conversation_session_id.clone(),
run_id: live.identity.run_id.clone(),
start_action_id: live.identity.start_action_id.clone(),
start_action_fingerprint: live.identity.start_action_fingerprint.clone(),
process_id: live.process_id.clone(),
output: output.text.clone(),
output_sha256,
output_bytes: output.text.len(),
updated_at: unix_timestamp(),
};
let transcript_result = write_agent_runtime_json_sidecar_with_max_bytes(
&live.root,
&process_session_transcript_relative_path(&live.process_id),
"Agent Runtime process transcript",
&transcript,
PROCESS_SESSION_TRANSCRIPT_MAX_BYTES,
);
if transcript_result.is_err() {
output.needs_reconciliation = true;
}
let record = process_session_record_from_live(live, &output);
let record_persisted = write_process_session_record(&live.root, &record).is_ok();
if !record_persisted {
output.needs_reconciliation = true;
}
let needs_reconciliation = output.needs_reconciliation;
// 只有 live registry 已收束后才能发布可信终态。等待 `output_changed` 的调用方
// 可能立即执行 run 级取消清理,而 registry 中的任何 live 条目都会被视为未完成会话。
if record_persisted && !needs_reconciliation {
if let Ok(mut registry) = process_session_registry().lock() {
registry.sessions.remove(&live.process_id);
}
}
live.output_changed.notify_all();
drop(output);
}