blob: b184f10aadbc782a86770e36fbbee46a7ee2c3b4 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
mod control;
use super::{
Result, ShellError, Update,
control::{Control, ControlError},
handle::StopState,
output::Output,
terminal::Terminal,
};
use futures_util::FutureExt;
use maka_agent_facts::shell_run::ShellOutcome;
use maka_agent_facts::shell_run::ShellOutput;
use maka_agent_facts::shell_run::ShellPatch;
use maka_agent_facts::shell_run::ShellState;
use maka_agent_facts::shell_run::{ShellObservation, ShellRun};
use maka_agent_facts::terminal::TerminalSize;
use maka_event_log::EventLog;
use maka_process::pty::{self, PtyChild, PtyCommand, PtyIo};
use std::{
num::NonZeroI64,
process::ExitStatus,
sync::{Arc, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken;
pub(super) struct Worker {
pub log: Arc<EventLog>,
pub record: Arc<ShellRun>,
pub commands: mpsc::Receiver<Control>,
pub updates: watch::Sender<Update>,
pub cancellation: CancellationToken,
pub stop: Arc<Mutex<StopState>>,
pub host_drain: CancellationToken,
pub persistence_failed: bool,
pub output: Output,
}
impl Worker {
pub async fn run(mut self, command: PtyCommand, size: TerminalSize) -> Result<()> {
let mut terminal = Terminal::new(size, self.output.clone());
let result = self.run_terminal(command, size, &mut terminal).await;
terminal.close();
result
}
async fn run_terminal(
&mut self,
command: PtyCommand,
size: TerminalSize,
terminal: &mut Terminal,
) -> Result<()> {
let mut initial = (*self.record).clone();
initial.output = ShellOutput::Pty {
screen: terminal.display(),
};
if self.cancellation.is_cancelled() {
return Err(ShellError::CancelledBeforeAdmission);
}
// T1: neither a native handle nor a process exists before this settles.
self.record = match self.log.create_shell_run(initial).await {
Ok(record) => Arc::new(record),
Err(error) => {
if matches!(
error,
maka_event_log::StoreError::CommitUnknown(_)
| maka_event_log::StoreError::OperationUnknown
) {
self.host_drain.cancel();
}
return Err(error.into());
}
};
let spawned = if self.cancellation.is_cancelled() {
Err(maka_agent_facts::tools::ToolError::Failed(
"PTY launch cancelled before spawn".into(),
))
} else {
pty::spawn(command, size).await
};
let (mut child, io) = match spawned {
Ok(handles) => handles,
Err(error @ maka_agent_facts::tools::ToolError::CleanupUnconfirmed(_)) => {
self.host_drain.cancel();
return Err(error.into());
}
Err(error) => {
return self
.persist(terminal, Some(finished(failed(&error))?))
.await;
}
};
let result = match self.persist(terminal, Some(ShellState::Running)).await {
Ok(()) => self.active(terminal, &mut child, &io).await,
Err(error) => Err(error),
};
self.commands.close();
terminal.fail_writes("PTY is closing");
// Pending commands have performed no effect; acknowledge that explicitly.
while let Ok(control) = self.commands.try_recv() {
let _ = control
.reply
.send(Err(ControlError::new("PTY is closing", 0)));
}
let mut outcome = result.unwrap_or_else(|error| failed(&error));
let mut cleanup_failure = None;
if !matches!(
outcome,
ShellOutcome::Completed | ShellOutcome::Exited { .. }
) {
match child.terminate() {
Ok(applied) => {
self.stop.lock().unwrap().signal_applied =
Some(matches!(outcome, ShellOutcome::Cancelled { .. }) && applied);
}
Err(error) => {
self.stop.lock().unwrap().signal_applied = None;
outcome = failed(&error);
self.host_drain.cancel();
cleanup_failure = Some(error);
}
}
}
// Root exit, console close and final output are distinct fences. Never
// await ClosePseudoConsole without a concurrent consumer of its output.
let exited = CancellationToken::new();
let closing = async {
let result = child.wait().await;
exited.cancel();
result?;
child.close().await
};
let draining = super::terminal::drain(terminal, &io, &exited);
let (closed, drained) = tokio::join!(closing, draining);
if let Err(error) = closed {
self.host_drain.cancel();
cleanup_failure.get_or_insert(error);
}
if let Err(error) = drained {
outcome = failed(&error);
}
drop(io);
drop(child);
terminal.fail_writes("PTY closed");
self.settle(terminal, outcome, cleanup_failure).await
}
async fn settle(
&mut self,
terminal: &mut Terminal,
outcome: ShellOutcome,
cleanup_failure: Option<std::io::Error>,
) -> Result<()> {
if let Some(error) = cleanup_failure {
self.host_drain.cancel();
// Neither a terminal process state nor released Rust handles prove
// native cleanup. Keep T1 recoverable and propagate the exact class
// through the original worker update and request resource ticket.
return Err(
maka_agent_facts::tools::ToolError::CleanupUnconfirmed(error.to_string()).into(),
);
}
if self.persistence_failed {
// A commit-unknown is not repaired by writing a guessed next state.
return Err(ShellError::Rejected(
"PTY persistence failed; durable outcome requires recovery",
));
}
// T2 and final screen become visible together, after native cleanup.
self.persist(terminal, Some(finished(outcome)?)).await
}
async fn active(
&mut self,
terminal: &mut Terminal,
child: &mut PtyChild,
io: &PtyIo,
) -> Result<ShellOutcome> {
let timeout_ms = self.record.timeout_ms;
let timeout = async move {
match timeout_ms {
Some(ms) => tokio::time::sleep(Duration::from_millis(ms)).await,
None => std::future::pending().await,
}
};
tokio::pin!(timeout);
let mut buffer = [0; 16 * 1024];
let mut eof = false;
loop {
tokio::select! {
_ = self.cancellation.cancelled() => return Ok(ShellOutcome::Cancelled { message: None }),
_ = &mut timeout => return Ok(ShellOutcome::TimedOut { message: None }),
status = child.wait() => return Ok(exit_outcome(status?)),
read = io.read(&mut buffer), if !eof => {
let mut count = read?;
eof = count == 0;
// Coalesce only ready bytes, within the existing 16 KiB cut.
// Both native readers are cancellation-safe; never wait here
// for a fuller batch or delay a pending input/stop for one.
let mut failure = None;
while !eof && count < buffer.len() {
match io.read(&mut buffer[count..]).now_or_never() {
Some(Ok(0)) => eof = true,
Some(Ok(read)) => count += read,
Some(Err(error)) => { failure = Some(error); break; }
None => break,
}
}
// Complete the parser cut before persisting or handling controls.
terminal.output(&buffer[..count], eof)?;
self.publish(terminal);
if let Some(error) = failure { return Err(error.into()); }
}
written = async { io.write(terminal.writes.front().unwrap().remaining()).await }, if !terminal.writes.is_empty() => {
let count = written?;
if count == 0 { return Err(std::io::Error::from(std::io::ErrorKind::WriteZero).into()); }
if terminal.writes.front_mut().unwrap().advance(count) {
terminal.writes.pop_front().unwrap().finish(self.current_cut());
}
}
command = self.commands.recv(), if terminal.writes.is_empty() => {
match command {
Some(control) => self.control(terminal, child, control).await?,
None => return Ok(ShellOutcome::Cancelled { message: None }),
}
}
}
}
}
fn publish(&mut self, terminal: &Terminal) {
let observation = ShellObservation {
record: self.record.clone(),
preview: Some(terminal.preview.clone()),
};
if self.updates.borrow().as_ref().is_some_and(|update| {
update.as_ref().is_ok_and(|previous| {
Arc::ptr_eq(&previous.record, &observation.record)
&& previous.preview == observation.preview
})
}) {
return;
}
self.updates.send_replace(Some(Ok(Arc::new(observation))));
}
async fn persist(&mut self, terminal: &Terminal, state: Option<ShellState>) -> Result<()> {
let output = ShellOutput::Pty {
screen: terminal.display(),
};
let patch = ShellPatch {
state,
output: Some(output),
updated_at: Some(now()?),
..Default::default()
};
match self
.log
.patch_shell_run(&self.record.session_id, &self.record.id, patch)
.await
{
Ok(record) => {
self.record = Arc::new(record);
self.publish(terminal);
Ok(())
}
Err(error) => {
self.persistence_failed = true;
self.host_drain.cancel();
Err(error.into())
}
}
}
}
pub(super) fn now() -> Result<u64> {
u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| ShellError::Rejected("clock predates Unix epoch"))?
.as_millis(),
)
.map_err(|_| ShellError::Rejected("clock exceeds shell timestamp range"))
}
pub(super) fn finished(outcome: ShellOutcome) -> Result<ShellState> {
Ok(ShellState::Terminal {
completed_at: now()?,
outcome,
observed_at: None,
})
}
pub(super) fn failed(error: &impl std::fmt::Display) -> ShellOutcome {
ShellOutcome::Failed {
message: error.to_string().chars().take(512).collect(),
}
}
pub(super) fn exit_outcome(status: ExitStatus) -> ShellOutcome {
if status.success() {
return ShellOutcome::Completed;
}
#[cfg(unix)]
let code = {
use std::os::unix::process::ExitStatusExt;
status
.code()
.map(i64::from)
.or_else(|| status.signal().map(|s| 128 + i64::from(s)))
};
#[cfg(windows)]
let code = status.code().map(|code| i64::from(code as u32));
match code.and_then(NonZeroI64::new) {
Some(code) => ShellOutcome::Exited {
code,
message: None,
},
None => failed(&"PTY exited without an exit code"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use maka_agent_facts::shell_run::ShellPermissions;
use maka_agent_facts::shell_run::ShellVisibility;
#[tokio::test]
async fn native_cleanup_failure_preserves_unsettled_row_and_typed_worker_error() {
let directory = tempfile::tempdir().unwrap();
let log = Arc::new(
EventLog::open(&directory.path().join("events.sqlite"))
.await
.unwrap(),
);
log.create_session("session", "create", &serde_json::json!({}), 1)
.await
.unwrap();
let size = TerminalSize::new(80, 24).unwrap();
let output = Output::new(size, watch::channel(()).0).unwrap();
let mut terminal = Terminal::new(size, output.clone());
let record = log
.create_shell_run(ShellRun {
id: "cleanup-failure".into(),
session_id: "session".into(),
source_run_id: None,
source_turn_id: "turn".into(),
source_tool_call_id: "tool".into(),
visibility: ShellVisibility::Model,
permissions: ShellPermissions {
boundary_revision: 0,
sandbox: maka_sandbox::Sandbox::Disabled,
},
cwd: directory.path().to_str().unwrap().into(),
command: "fixture".into(),
started_at: 1,
updated_at: 1,
timeout_ms: None,
revision: 1,
state: ShellState::Starting,
output: ShellOutput::Pty {
screen: terminal.display(),
},
})
.await
.unwrap();
let (_, commands) = mpsc::channel(1);
let (updates, _) = watch::channel(None);
let drain = CancellationToken::new();
let mut worker = Worker {
log: log.clone(),
record: Arc::new(record),
commands,
updates,
cancellation: CancellationToken::new(),
stop: Arc::default(),
host_drain: drain.clone(),
persistence_failed: false,
output,
};
terminal
.output(b"live output before cleanup failure", false)
.unwrap();
worker.publish(&terminal);
let live = worker.updates.borrow().clone().unwrap().unwrap();
assert!(
live.preview
.as_ref()
.unwrap()
.screen
.contains("live output before cleanup failure")
);
assert_eq!((live.revision, live.updated_at), (1, 1));
let error = worker
.settle(
&mut terminal,
ShellOutcome::Completed,
Some(std::io::Error::other("native PTY close failed")),
)
.await
.unwrap_err();
assert!(matches!(
error,
ShellError::Process(maka_agent_facts::tools::ToolError::CleanupUnconfirmed(_))
));
assert!(drain.is_cancelled());
let durable = log
.read_shell_run("session", "cleanup-failure")
.await
.unwrap()
.unwrap();
assert!(
durable.state.active(),
"failed cleanup must not manufacture a clean T2"
);
assert_eq!(durable.revision, 1);
let ShellOutput::Pty { screen } = &durable.output else {
unreachable!()
};
assert!(
!screen
.text(50 * 1024)
.screen
.contains("live output before cleanup failure")
);
drop(worker);
assert_eq!(log.recover_shell_runs(50).await.unwrap(), 1);
let recovered = log
.read_shell_run("session", "cleanup-failure")
.await
.unwrap()
.unwrap();
assert_eq!(recovered.output, durable.output);
assert_eq!(recovered.revision, 2);
assert!(matches!(
recovered.state,
ShellState::Terminal {
outcome: ShellOutcome::Orphaned { .. },
..
}
));
log.shutdown().await.unwrap();
}
}