wip: bound tool output when persistence fails

C02-12: classic and adaptive write, integrity and immutable-replay failures now retain the existing UTF-8 head/tail projection with an honest unsaved footer. Remove stale artifact claims and refuse to persist a lossy preview as exact output. Added filesystem failure and conflict fixtures for successful and failed results, preserving existing files and metadata. Focused runtime verification and fix-off controls are pending in the governed integration batch; formatting and diff checks pass.
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-30 03:17:18 -07:00
1 parent b10208a7ee
commit a2be5b89d7
2 files changed
+192 -4

No files matched your search

+1 -1
View File
@@ -196,7 +196,7 @@ fn tool_error_message_mut(error: &mut ToolError) -> Option<&mut String> {
/// Give an oversized tool error message the bounded head/tail projection and
/// saved artifact a failed result gets (C02-12). Blocking: it may write the
/// artifact, so callers run it under `spawn_blocking`. A failed artifact
/// write degrades to the original message, as spillover does for results.
/// write leaves a bounded preview that says the full output could not be saved.
fn bound_oversized_tool_error(
mut error: ToolError,
tool_id: &str,
+191 -3
View File
@@ -454,8 +454,8 @@ fn truncated_preview(
/// happened (content small enough, error result, write failure).
/// Failures are logged but never bubble up — a tool that produced a
/// result shouldn't be marked failed because the spillover writer
/// couldn't reach disk; we degrade to no-op and the model gets the
/// original (large) content.
/// couldn't reach disk; a bounded preview then says the full output could
/// not be saved, without advertising a recovery path or artifact.
///
/// Error results (`success == false`) are skipped: error messages
/// are typically short, and turning them into a truncated preview
@@ -547,8 +547,9 @@ fn apply_spillover_inner(
target: "spillover",
?err,
tool_id,
"spillover write failed; passing original content through"
"spillover write failed; retaining a bounded unsaved preview"
);
bound_unpersisted_output(result, SPILLOVER_HEAD_BYTES, SPILLOVER_TAIL_BYTES);
return None;
}
};
@@ -691,6 +692,56 @@ fn metadata_object_mut(result: &mut ToolResult) -> &mut serde_json::Map<String,
.expect("metadata was just made an object")
}
/// Bound a failed persistence attempt without turning its preview into exact
/// evidence. The same windows and footer authority serve both routing modes.
fn bound_unpersisted_output(result: &mut ToolResult, head_bytes: usize, tail_bytes: usize) {
let original = &result.content;
let (head, tail) = head_tail_windows(original, head_bytes, tail_bytes);
let omitted = original.len().saturating_sub(head.len() + tail.len());
if omitted == 0 {
return;
}
let original_bytes = original.len();
let original_lines = original.lines().count();
let head_len = head.len();
let tail_len = tail.len();
let omitted_lines = original[head_len..original_bytes - tail_len]
.lines()
.count();
let digest = crate::hashing::sha256_hex(original.as_bytes());
result.content = format!(
"{head}\n\n{}\n\n…\n{tail}",
preview_footer(omitted, omitted_lines, None, None)
);
let object = metadata_object_mut(result);
// A prior metadata envelope cannot claim that it retained these bytes.
for key in [
"spillover_path",
"legacy_spillover_path",
"artifact_path",
"artifact_id",
"artifact_session_id",
"artifact_relative_path",
"artifact_byte_size",
"artifact_digest",
"artifact_generation",
"artifact_encoding",
"artifact_retention_state",
"artifact_preview",
"artifact_record",
] {
object.remove(key);
}
object.insert("evidence_available".into(), false.into());
object.insert("output_persistence_failed".into(), true.into());
object.insert("truncated".into(), true.into());
object.insert("content_digest".into(), format!("sha256:{digest}").into());
object.insert("original_byte_count".into(), original_bytes.into());
object.insert("original_line_count".into(), original_lines.into());
object.insert("retained_head_bytes".into(), head_len.into());
object.insert("retained_tail_bytes".into(), tail_len.into());
}
/// Stamp the keys the TUI, receipts and `retrieve_tool_result` use to find a
/// session artifact that holds a tool call's full output.
fn stamp_artifact_metadata(
@@ -732,6 +783,17 @@ pub(crate) fn preserve_full_output_for_model_context(
tool_name: &str,
session_id: &str,
) -> bool {
if result
.metadata
.as_ref()
.and_then(|metadata| metadata.get("output_persistence_failed"))
.and_then(serde_json::Value::as_bool)
== Some(true)
{
// Persistence already lost the omitted bytes. Saving this preview as
// the full output would manufacture an exact-evidence receipt.
return false;
}
if result
.metadata
.as_ref()
@@ -995,17 +1057,20 @@ fn apply_adaptive_evidence_inner(
}
Ok(_) => {
tracing::warn!(target: "evidence", tool_id, "adaptive evidence replay conflicts with immutable metadata");
bound_unpersisted_output(result, head_bytes, tail_bytes);
return None;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if let Err(err) = publish_evidence_metadata(context.session_id, &proposed_artifact) {
tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence metadata publication failed");
bound_unpersisted_output(result, head_bytes, tail_bytes);
return None;
}
proposed_artifact
}
Err(err) => {
tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence metadata validation failed");
bound_unpersisted_output(result, head_bytes, tail_bytes);
return None;
}
};
@@ -1024,6 +1089,7 @@ fn apply_adaptive_evidence_inner(
Ok(paths) => paths,
Err(err) => {
tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence content publication failed");
bound_unpersisted_output(result, head_bytes, tail_bytes);
return None;
}
};
@@ -1269,6 +1335,128 @@ mod tests {
)
}
fn assert_unsaved_preview(result: &mut ToolResult, original: &str, success: bool) {
assert_eq!(result.success, success);
assert!(result.content.len() < SPILLOVER_HEAD_BYTES + SPILLOVER_TAIL_BYTES + 1024);
assert!(result.content.starts_with("first 🐳\n"));
assert!(result.content.ends_with("last 🐳\n"));
assert!(
result
.content
.contains("the full output could not be saved")
);
assert!(result.content.contains("re-run with narrower output"));
assert!(!result.content.contains("full output at"));
assert!(!result.content.contains("retrieve_tool_result"));
let metadata = result.metadata.as_ref().unwrap();
assert_eq!(metadata["original_byte_count"], original.len());
assert_eq!(metadata["evidence_available"], false);
assert_eq!(metadata["output_persistence_failed"], true);
assert_eq!(metadata["truncated"], true);
assert_eq!(metadata["fixture"], "preserved");
assert_eq!(
metadata["content_digest"],
format!("sha256:{}", crate::hashing::sha256_hex(original.as_bytes()))
);
assert!(metadata.get("artifact_id").is_none());
assert!(metadata.get("spillover_path").is_none());
assert!(!preserve_full_output_for_model_context(
result,
"retry",
"exec_shell",
"retry-session"
));
}
#[test]
fn classic_spillover_write_failure_bounds_success_and_error_without_fake_evidence() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let root = spillover_root().unwrap();
fs::create_dir_all(root.parent().unwrap()).unwrap();
fs::write(&root, "keep this file").unwrap();
let original = format!("first 🐳\n{}last 🐳\n", "middle 🐳\n".repeat(20_000));
for success in [true, false] {
let mut result = ToolResult::success(original.clone());
result.success = success;
result.metadata = Some(serde_json::json!({"fixture": "preserved"}));
assert!(apply_spillover_inner(&mut result, "call-failed", None, true).is_none());
assert_unsaved_preview(&mut result, &original, success);
}
assert_eq!(fs::read_to_string(root).unwrap(), "keep this file");
});
}
#[test]
fn adaptive_persistence_failures_bound_output_and_keep_existing_evidence_immutable() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let original = format!("first 🐳\n{}last 🐳\n", "middle 🐳\n".repeat(20_000));
for failure in [
"metadata-write",
"metadata-invalid",
"metadata-conflict",
"content-conflict",
] {
for success in [true, false] {
let session = format!("{failure}-{success}");
let directory = tmp
.path()
.join(".codewhale/sessions")
.join(&session)
.join("artifacts");
fs::create_dir_all(directory.parent().unwrap()).unwrap();
let canary = match failure {
"metadata-write" => directory.clone(),
"metadata-invalid" | "metadata-conflict" => {
directory.join("art_call-failed.evidence.json")
}
_ => directory.join("art_call-failed.txt"),
};
if failure != "metadata-write" {
fs::create_dir_all(&directory).unwrap();
}
if failure == "metadata-conflict" {
let mut retained =
ToolResult::success("retained evidence\n".repeat(10_000));
adaptive_spillover(&mut retained, "call-failed", "exec_shell", &session)
.unwrap();
} else {
fs::write(&canary, "keep this file").unwrap();
}
let canary_before = fs::read(&canary).unwrap();
let mut result = ToolResult::success(original.clone());
result.success = success;
result.metadata = Some(serde_json::json!({
"fixture": "preserved",
"artifact_id": "stale",
"spillover_path": "/stale",
"evidence_available": true,
}));
assert!(
adaptive_spillover(&mut result, "call-failed", "exec_shell", &session)
.is_none()
);
assert_unsaved_preview(&mut result, &original, success);
assert_eq!(fs::read(&canary).unwrap(), canary_before);
if failure == "metadata-conflict" {
assert_eq!(
fs::read_to_string(directory.join("art_call-failed.txt")).unwrap(),
"retained evidence\n".repeat(10_000)
);
}
}
}
assert!(
!tmp.path()
.join(".codewhale/sessions/retry-session")
.exists()
);
});
}
/// The old hint named `read_file`, which is not registered for the model
/// at all, and otherwise pointed at routes that only reach the artifact
/// under trust mode (see [`spillover_recovery_instruction`]), while never