fix: retain command failure stages and output collection state
This commit is contained in:
@@ -367,7 +367,7 @@ impl BashTool {
|
||||
guard.handle = None;
|
||||
|
||||
let timed_out = terminal.status == workdir::CommandStatus::TimedOut;
|
||||
let summary =
|
||||
let mut summary =
|
||||
if cancellation_requested || terminal.status == workdir::CommandStatus::Cancelled {
|
||||
format!("$ {cmd_summary} (cancelled)")
|
||||
} else if timed_out {
|
||||
@@ -379,6 +379,12 @@ impl BashTool {
|
||||
None => format!("$ {cmd_summary} (terminated)"),
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
terminal.output_collection,
|
||||
workdir::CommandOutputCollection::Incomplete { .. }
|
||||
) {
|
||||
summary.push_str(" (output incomplete)");
|
||||
}
|
||||
Ok(BashResult {
|
||||
summary,
|
||||
stdout_preview: String::from_utf8_lossy(&stdout_preview).into_owned(),
|
||||
|
||||
@@ -68,8 +68,10 @@ pub struct OutputRange {
|
||||
pub struct OutputTerminal {
|
||||
pub status: Option<CommandStatus>,
|
||||
pub exit_code: Option<i32>,
|
||||
/// False on a forcibly dropped collector or failed transport: received bytes are retained,
|
||||
/// but they are not a claim about the unobserved process tail.
|
||||
pub process_failure: Option<workdir::CommandFailure>,
|
||||
pub output_collection: Option<workdir::CommandOutputCollection>,
|
||||
/// True only for confirmed EOF collection (quota is reported separately). A terminal
|
||||
/// fence or exit zero alone does not confirm the unobserved process tail.
|
||||
pub transport_complete: bool,
|
||||
}
|
||||
|
||||
@@ -405,12 +407,9 @@ impl BashOutputWriter {
|
||||
terminal: &workdir::CommandTerminal,
|
||||
) -> Result<BashOutputMetadata, String> {
|
||||
let mut r = self.record.lock().unwrap();
|
||||
if terminal.stdout_delivered_bytes != r.stdout_seen
|
||||
if !terminal.metadata_valid()
|
||||
|| terminal.stdout_delivered_bytes != r.stdout_seen
|
||||
|| terminal.stderr_delivered_bytes != r.stderr_seen
|
||||
|| terminal.stdout_bytes < r.stdout_seen
|
||||
|| terminal.stderr_bytes < r.stderr_seen
|
||||
|| ((terminal.stdout_bytes != r.stdout_seen || terminal.stderr_bytes != r.stderr_seen)
|
||||
!= terminal.truncation_reason.is_some())
|
||||
{
|
||||
return Err("command terminal counts do not fence delivered bytes".into());
|
||||
}
|
||||
@@ -419,7 +418,12 @@ impl BashOutputWriter {
|
||||
r.terminal = Some(OutputTerminal {
|
||||
status: Some(terminal.status),
|
||||
exit_code: terminal.exit_code,
|
||||
transport_complete: true,
|
||||
transport_complete: matches!(
|
||||
terminal.output_collection,
|
||||
workdir::CommandOutputCollection::Complete
|
||||
),
|
||||
process_failure: terminal.process_failure,
|
||||
output_collection: Some(terminal.output_collection),
|
||||
});
|
||||
Ok(r.metadata())
|
||||
}
|
||||
@@ -439,6 +443,8 @@ impl Drop for BashOutputWriter {
|
||||
status: None,
|
||||
exit_code: None,
|
||||
transport_complete: false,
|
||||
process_failure: None,
|
||||
output_collection: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -456,6 +462,8 @@ mod tests {
|
||||
status: Some(CommandStatus::Completed),
|
||||
exit_code: Some(0),
|
||||
transport_complete: true,
|
||||
process_failure: None,
|
||||
output_collection: Some(workdir::CommandOutputCollection::Complete),
|
||||
})
|
||||
.id
|
||||
}
|
||||
@@ -514,6 +522,8 @@ mod tests {
|
||||
status: Some(CommandStatus::Completed),
|
||||
exit_code: Some(0),
|
||||
transport_complete: true,
|
||||
process_failure: None,
|
||||
output_collection: Some(workdir::CommandOutputCollection::Complete),
|
||||
});
|
||||
assert!(root.metadata(&metadata.id).is_some());
|
||||
let _replacement = sibling.begin().unwrap();
|
||||
@@ -582,7 +592,7 @@ mod tests {
|
||||
let root = BashOutputView::new();
|
||||
let mut writer = root.begin().unwrap();
|
||||
writer.append(CommandStream::Stdout, b"abc");
|
||||
let mut terminal = serde_json::json!({"status":"completed","exit_code":0,"stdout_bytes":9,"stderr_bytes":0,"stdout_delivered_bytes":4,"stderr_delivered_bytes":0,"truncation_reason":"byte_budget"});
|
||||
let mut terminal = serde_json::json!({"status":"completed","exit_code":0,"process_failure":null,"output_collection":{"state":"complete"},"stdout_bytes":9,"stderr_bytes":0,"stdout_delivered_bytes":4,"stderr_delivered_bytes":0,"truncation_reason":"byte_budget"});
|
||||
assert!(
|
||||
writer
|
||||
.finish_transport(&serde_json::from_value(terminal.clone()).unwrap())
|
||||
|
||||
@@ -416,6 +416,7 @@ struct ExternalValidatingSession {
|
||||
truncate_first_binary_response: bool,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize,
|
||||
lose_terminal_only: bool,
|
||||
collection_override: Option<workdir::CommandOutputCollection>,
|
||||
mutate_after_first_binary_response: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
@@ -534,7 +535,11 @@ impl workdir::WorkdirSession for ExternalValidatingSession {
|
||||
&self,
|
||||
request: workdir::CommandOutputRequest,
|
||||
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
let output = self.inner.command_output(request).await?;
|
||||
let mut output = self.inner.command_output(request).await?;
|
||||
if let (Some(collection), Some(terminal)) = (self.collection_override, &mut output.terminal)
|
||||
{
|
||||
terminal.output_collection = collection;
|
||||
}
|
||||
if (!self.lose_terminal_only || output.terminal.is_some())
|
||||
&& self
|
||||
.command_response_losses
|
||||
@@ -575,6 +580,7 @@ async fn external_view_image_uses_a_protocol_bounded_request() {
|
||||
truncate_first_binary_response: false,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||
lose_terminal_only: false,
|
||||
collection_override: None,
|
||||
mutate_after_first_binary_response: None,
|
||||
});
|
||||
let png = include_bytes!("fixtures/view-image/valid.png");
|
||||
@@ -600,6 +606,7 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
||||
truncate_first_binary_response: true,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||
lose_terminal_only: false,
|
||||
collection_override: None,
|
||||
mutate_after_first_binary_response: None,
|
||||
});
|
||||
let (_meta, tool) = view_image_tool(truncated)();
|
||||
@@ -618,6 +625,7 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
||||
truncate_first_binary_response: false,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||
lose_terminal_only: false,
|
||||
collection_override: None,
|
||||
mutate_after_first_binary_response: Some(image_path),
|
||||
});
|
||||
let (_meta, tool) = view_image_tool(changing)();
|
||||
@@ -1104,7 +1112,20 @@ async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
||||
let execution = tokio::spawn(async move {
|
||||
executing.execute(r#"{"command":"printf 'before'; printf 'err-before' >&2; while :; do :; done","cwd":"."}"#, execution_context).await
|
||||
});
|
||||
await_command_output(&mut events).await;
|
||||
// Cancellation must follow both writes, not merely the first stdout chunk.
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
let mut ready = [false; 2];
|
||||
while !ready.iter().all(|seen| *seen) {
|
||||
if let workdir::CommandEvent::Output { stream, .. } = events.recv().await.unwrap() {
|
||||
ready[match stream {
|
||||
workdir::CommandStream::Stdout => 0,
|
||||
workdir::CommandStream::Stderr => 1,
|
||||
}] = true;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("both streams written before cancellation");
|
||||
bash.cancel_execution(&context).await.unwrap();
|
||||
let ToolError::Cancelled(output) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), execution)
|
||||
@@ -1281,6 +1302,7 @@ async fn lost_page_and_terminal_responses_replay_without_restarting_or_double_ap
|
||||
mutate_after_first_binary_response: None,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(1),
|
||||
lose_terminal_only,
|
||||
collection_override: None,
|
||||
});
|
||||
let view = tools::bash_output::BashOutputView::new();
|
||||
let definitions = core_builtin_tools(session.clone(), Tracker::new(), view.clone());
|
||||
@@ -1315,3 +1337,80 @@ async fn lost_page_and_terminal_responses_replay_without_restarting_or_double_ap
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Provider metadata is the fixture here; actual read/drain injection and HTTP
|
||||
// forwarding are tested by workdir's production-supervisor suite.
|
||||
#[tokio::test]
|
||||
async fn exit_zero_with_incomplete_output_keeps_partial_bytes_and_safe_failure_in_retained_evidence()
|
||||
{
|
||||
use workdir::{
|
||||
CommandFailure, CommandFailureStage as Stage, CommandIoErrorKind, CommandOutputCollection,
|
||||
};
|
||||
for stage in [
|
||||
Stage::StdoutRead,
|
||||
Stage::StderrRead,
|
||||
Stage::StdoutDrain,
|
||||
Stage::StderrDrain,
|
||||
Stage::OutputDelivery,
|
||||
] {
|
||||
let root = TempDir::new().unwrap();
|
||||
let failure = CommandFailure {
|
||||
stage,
|
||||
reason: CommandIoErrorKind::PermissionDenied,
|
||||
os_code: None,
|
||||
};
|
||||
let collection = CommandOutputCollection::Incomplete { failure };
|
||||
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(
|
||||
Scope::writable(root.path()).unwrap(),
|
||||
root.path().into(),
|
||||
),
|
||||
truncate_first_binary_response: false,
|
||||
mutate_after_first_binary_response: None,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||
lose_terminal_only: false,
|
||||
collection_override: Some(collection),
|
||||
});
|
||||
let view = tools::bash_output::BashOutputView::new();
|
||||
let (_, bash) = tools::bash_tool(session.clone(), view.clone())();
|
||||
let out = call(
|
||||
&bash,
|
||||
json!({"cwd":".","command":"printf partial; printf err >&2"}),
|
||||
)
|
||||
.await;
|
||||
assert!(out.summary.ends_with("(output incomplete)"));
|
||||
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||
let metadata = &body["output"];
|
||||
assert_eq!(body["stdout_preview"], "partial");
|
||||
assert_eq!(body["stderr_preview"], "err");
|
||||
assert_eq!(body["preview_omitted"], false);
|
||||
assert_eq!(metadata["terminal"]["exit_code"], 0);
|
||||
assert_eq!(metadata["terminal"]["status"], "completed");
|
||||
assert_eq!(metadata["terminal"]["transport_complete"], false);
|
||||
assert_eq!(
|
||||
metadata["terminal"]["output_collection"],
|
||||
serde_json::to_value(collection).unwrap()
|
||||
);
|
||||
assert_eq!(metadata["storage_truncated"], false);
|
||||
assert_eq!(metadata["transport_truncation"], serde_json::Value::Null);
|
||||
assert_eq!(metadata["stdout_seen"], 7);
|
||||
assert_eq!(metadata["stdout_delivered"], 7);
|
||||
let id = metadata["id"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(view.metadata(id).unwrap()).unwrap(),
|
||||
*metadata
|
||||
);
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stdout, 0, 100)
|
||||
.unwrap(),
|
||||
b"partial"
|
||||
);
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stderr, 0, 100)
|
||||
.unwrap(),
|
||||
b"err"
|
||||
);
|
||||
assert!(!metadata.to_string().contains(root.path().to_str().unwrap()));
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user