Compare commits

...
2 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 5f00cab707 wip: handle FileWrites in TUI mailbox matches
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:30:17 -08:00
CodeWhale BotandClaude Sonnet 5.5 7e5bc98613 wip: count sub-agent file writes as the turn's own for undo
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:56:46 -08:00
14 changed files with 229 additions and 5 deletions

No files matched your search

+21 -3
View File
@@ -9476,12 +9476,15 @@ enum SpanWriter {
/// Split a window's changes into the turn's own and everyone else's, from
/// the spans its receipts bound: a path belongs to the turn only if it
/// changed while one of the turn's tool calls was running and, for a file
/// tool, is one the call declared. A span whose changes were not recorded
/// tool, is one the call declared. A path a sub-agent the turn started
/// reported writing through a bounded write tool is the turn's own wherever
/// it changed in the window. A span whose changes were not recorded
/// (a snapshot in it failed) cannot be attributed and fails closed.
fn attribute_window(
workspace: &FsPath,
turn_id: &str,
receipts: &[codewhale_snapshot::WorkspaceSnapshotRef],
delegated: &std::collections::BTreeSet<PathBuf>,
) -> Result<
(
std::collections::BTreeSet<PathBuf>,
@@ -9559,6 +9562,11 @@ fn attribute_window(
}
};
}
for path in delegated {
if foreign.remove(path) {
owned.insert(path.clone());
}
}
Ok((owned, foreign))
}
@@ -9733,7 +9741,12 @@ fn patch_undo_workspace_files_impl(
})
.filter_map(|receipt| receipt.write_paths.as_ref())
.flatten();
for raw in turn.declared_writes.iter().chain(receipt_writes) {
for raw in turn
.declared_writes
.iter()
.chain(&turn.delegated_writes)
.chain(receipt_writes)
{
let covered = match declared_write_path(workspace, raw) {
Some(rel) => !repo.path_is_excluded(&rel).map_err(|e| {
ApiError::internal(format!(
@@ -9759,7 +9772,12 @@ fn patch_undo_workspace_files_impl(
for (turn, receipts) in windows {
let (pre, pre_label) = resolve(&turn.turn_id, &receipts[0])?;
let (post, _) = resolve(&turn.turn_id, &receipts[receipts.len() - 1])?;
let (owned, foreign) = attribute_window(workspace, &turn.turn_id, receipts)?;
let delegated = turn
.delegated_writes
.iter()
.filter_map(|raw| declared_write_path(workspace, raw))
.collect();
let (owned, foreign) = attribute_window(workspace, &turn.turn_id, receipts, &delegated)?;
segments.push(UndoSegment {
turn_id: turn.turn_id.clone(),
pre,
@@ -619,6 +619,7 @@ fn messages_from_thread_detail_batches_tool_results() {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
};
let item = |id: &str,
kind: TurnItemKind,
@@ -8155,6 +8156,7 @@ fn dropped_turn(
may_change_files,
snapshots,
declared_writes: Vec::new(),
delegated_writes: Vec::new(),
unrun_tool_calls: std::collections::BTreeSet::new(),
}
}
@@ -8281,6 +8283,76 @@ fn patch_undo_helper_refuses_to_overwrite_later_edits() -> Result<()> {
Ok(())
}
/// Files a turn's own sub-agents wrote land while no lead tool call is open.
/// They are the turn's own and the undo restores them; a file an editor
/// changed in the same window stays foreign and still refuses the undo,
/// naming only that file.
#[test]
fn patch_undo_helper_counts_sub_agent_writes_as_the_turns_own() -> Result<()> {
use codewhale_snapshot::WorkspaceSnapshotKind::{PostTurn, PreTurn};
if git_missing() {
return Ok(());
}
let _lock = lock_test_env();
let root = tempfile::tempdir()?;
let home = root.path().join("home");
fs::create_dir_all(&home)?;
let _home = EnvVarGuard::set("HOME", &home);
let workspace = root.path().join("workspace");
fs::create_dir_all(&workspace)?;
let repo = codewhale_snapshot::SnapshotRepo::open_or_init(&workspace)?;
let a = workspace.join("a.txt");
let b = workspace.join("b.txt");
let editor = workspace.join("editor.txt");
fs::write(&a, "a before")?;
fs::write(&b, "b before")?;
fs::write(&editor, "editor before")?;
let pre = repo.take_snapshot("pre-turn:1", Some("thr_a"))?;
fs::write(&a, "a child")?;
fs::write(&b, "b child")?;
fs::write(&editor, "editor typed")?;
let post = repo.take_snapshot("post-turn:1", Some("thr_a"))?;
let receipts = || {
let pre_receipt = snapshot_receipt(PreTurn, &pre, "thr_a");
let mut post_receipt = snapshot_receipt(PostTurn, &post, "thr_a");
post_receipt.changed_paths = Some(changed_between(&repo, &pre, &post));
vec![pre_receipt, post_receipt]
};
let mut turn = dropped_turn("turn_1", true, receipts());
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect_err("child writes without a receipt are foreign");
assert_eq!(err.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
turn.delegated_writes = vec!["a.txt".to_string(), "b.txt".to_string()];
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect_err("the editor's file must still refuse the undo");
assert_eq!(err.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
assert!(err.message.contains("editor.txt"), "{}", err.message);
assert!(!err.message.contains("a.txt"), "{}", err.message);
assert!(!err.message.contains("b.txt"), "{}", err.message);
assert_eq!(fs::read_to_string(&a)?, "a child");
assert_eq!(fs::read_to_string(&editor)?, "editor typed");
fs::write(&editor, "editor before")?;
let post = repo.take_snapshot("post-turn:1", Some("thr_a"))?;
let mut post_receipt = snapshot_receipt(PostTurn, &post, "thr_a");
post_receipt.changed_paths = Some(changed_between(&repo, &pre, &post));
let turn = codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots {
snapshots: vec![snapshot_receipt(PreTurn, &pre, "thr_a"), post_receipt],
delegated_writes: vec!["a.txt".to_string(), "b.txt".to_string()],
..turn
};
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect("the turn's sub-agent writes are undone");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&a)?, "a before");
assert_eq!(fs::read_to_string(&b)?, "b before");
assert_eq!(fs::read_to_string(&editor)?, "editor before");
Ok(())
}
/// No recorded restore point, an unclosed window, or a pruned / retagged
/// restore point: the undo fails honestly with a typed 409 instead of
/// forking over changed files. A turn that ran no tools needs none.
@@ -10646,6 +10718,7 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
};
// Mirror `TurnRecord::persist_effective_route` (private to the runtime
// threads module): persist the route envelope onto the turn so the
@@ -10858,6 +10931,7 @@ async fn session_save_persists_parent_cny_unpriced_reasons_without_double_count(
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
};
turn.effective_provider = Some(ProviderKind::Openai.as_str().to_string());
turn.effective_provider_id = Some(ProviderKind::Openai.as_str().to_string());
@@ -12224,6 +12298,7 @@ fn seed_summary_search_transcript(
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
latest_turn_id = Some(turn_id);
}
+11 -1
View File
@@ -1621,9 +1621,16 @@ async fn forward_subagent_mailbox_message(
owner_session_id: &str,
turn_id: &str,
seq: u64,
message: MailboxMessage,
turn_seq: u64,
mut message: MailboxMessage,
best_effort_sent_at: &mut HashMap<String, Instant>,
) -> bool {
if let MailboxMessage::FileWrites {
turn_seq: stamp, ..
} = &mut message
{
*stamp = turn_seq;
}
let event = Event::SubAgentMailbox {
owner_session_id: owner_session_id.to_string(),
turn_id: turn_id.to_string(),
@@ -6380,6 +6387,7 @@ impl Engine {
let tx_event_clone = self.tx_event.clone();
let mailbox_owner_session_id = self.session.id.clone();
let mailbox_turn_id = turn_id.to_string();
let mailbox_turn_seq = self.turn_counter;
let (flush_tx, mut flush_rx) = tokio::sync::oneshot::channel();
let drain_handle = spawn_supervised(
"subagent-mailbox-drainer",
@@ -6396,6 +6404,7 @@ impl Engine {
&mailbox_owner_session_id,
&mailbox_turn_id,
envelope.seq,
mailbox_turn_seq,
envelope.message,
&mut best_effort_sent_at,
).await {
@@ -6411,6 +6420,7 @@ impl Engine {
&mailbox_owner_session_id,
&mailbox_turn_id,
envelope.seq,
mailbox_turn_seq,
envelope.message,
&mut best_effort_sent_at,
).await {
+6
View File
@@ -1141,6 +1141,11 @@ pub struct TurnRecord {
/// carry none, and so have no restore point.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_snapshots: Vec<codewhale_snapshot::WorkspaceSnapshotRef>,
/// Workspace-relative paths the sub-agents this turn started changed in
/// the shared checkout through bounded write tools, as they reported
/// them. A turn-scoped undo counts them as the turn's own changes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegated_writes: Vec<String>,
}
impl TurnRecord {
@@ -1222,6 +1227,7 @@ impl TurnRecord {
&& self.artifacts.is_empty()
&& self.workspace.is_none()
&& self.workspace_snapshots.is_empty()
&& self.delegated_writes.is_empty()
&& self.source_accounting
&& self.source_usage_records.len() == 1
&& self
@@ -1185,6 +1185,7 @@ fn unaccepted_usage_turn(thread_id: &str, turn_id: &str) -> TurnRecord {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
}
}
@@ -3050,6 +3051,10 @@ pub struct DroppedTurnSnapshots {
/// only restore what the snapshots hold, so each is checked against the
/// snapshot exclusions.
pub declared_writes: Vec<String>,
/// Paths the turn's own sub-agents reported changing through bounded
/// write tools. They count as the turn's own changes, and are checked
/// against the snapshot exclusions like `declared_writes`.
pub delegated_writes: Vec<String>,
/// Tool calls recorded as failed, canceled or never started: whatever
/// paths their snapshot receipts declare, they wrote nothing.
pub unrun_tool_calls: std::collections::BTreeSet<String>,
@@ -10576,6 +10581,7 @@ impl RuntimeThreadManager {
&& (ran_tools || !ran_here),
snapshots: turn.workspace_snapshots.clone(),
declared_writes,
delegated_writes: turn.delegated_writes.clone(),
unrun_tool_calls,
}
})
@@ -11422,6 +11428,7 @@ impl RuntimeThreadManager {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
});
thread.latest_turn_id = Some(turn_id);
@@ -13712,6 +13719,7 @@ source_usage_records: Default::default(),
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
};
append_initial_routed_usage_to_turn(&mut turn, &initial_routed_usage);
@@ -14308,6 +14316,7 @@ source_usage_records: Default::default(),
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
};
let op = Op::CompactContext {
id: compaction_id.clone(),
@@ -16903,6 +16912,37 @@ source_usage_records: Default::default(),
self.append_unverified_routed_usage_to_turn(&turn_id, &source_id, *route)?;
}
}
EngineEvent::SubAgentMailbox {
owner_session_id,
turn_id: mailbox_turn_id,
message: crate::tools::subagent::MailboxMessage::FileWrites { paths, .. },
..
} if owner_session_id == thread_id => {
let belongs_to_turn = engine_turn_id
.as_deref()
.is_some_and(|started| started == mailbox_turn_id);
if belongs_to_turn {
let recorded = {
let _turn_mutation = self.store.turn_mutation.lock();
self.store.load_turn(&turn_id).and_then(|mut turn| {
for path in paths {
if !turn.delegated_writes.contains(&path) {
turn.delegated_writes.push(path);
}
}
self.store.save_turn(&turn)
})
};
if let Err(err) = recorded {
tracing::warn!(
target: "snapshot",
thread_id = %thread_id,
turn_id = %turn_id,
"sub-agent file writes were not recorded; undoing this turn will refuse them: {err:#}"
);
}
}
}
EngineEvent::CompactionStarted {
id, auto, message, ..
} => {
@@ -1081,6 +1081,7 @@ impl RuntimeThreadManager {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
};
let mut active = self.active.lock().await;
let Some(state) = active.external.get_mut(&thread.id) else {
@@ -2719,6 +2719,7 @@ fn sample_turn(thread_id: &str, turn_id: &str, status: RuntimeTurnStatus) -> Tur
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
}
}
@@ -20064,6 +20065,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
manager.store.save_turn(&TurnRecord {
source_usage_origin_turn_id: None,
@@ -20110,6 +20112,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
drop(manager);
@@ -20314,6 +20317,7 @@ fn seed_turns_with_user_messages(
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
turn_ids.push(turn_id);
}
@@ -21079,6 +21083,7 @@ fn restart_rebuild_restores_tool_call_identity_from_persisted_items() -> Result<
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
let turns = manager.store.list_turns_for_thread(&thread.id)?;
@@ -21372,6 +21377,7 @@ fn restart_rebuild_keeps_in_flight_tool_call_identity() -> Result<()> {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
let turns = manager.store.list_turns_for_thread(&thread.id)?;
@@ -21480,6 +21486,7 @@ fn restart_rebuild_skips_steers_the_engine_never_delivered() -> Result<()> {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
let turns = manager.store.list_turns_for_thread(&thread.id)?;
@@ -21589,6 +21596,7 @@ fn restart_rebuild_skips_legacy_tool_items_without_identity() -> Result<()> {
artifacts: Vec::new(),
workspace: None,
workspace_snapshots: Vec::new(),
delegated_writes: Vec::new(),
})?;
let turns = manager.store.list_turns_for_thread(&thread.id)?;
@@ -475,6 +475,20 @@ impl ChildAuthority {
return;
}
let mut manager = self.coordination_manager.write().await;
let shared_checkout = manager
.worker_records
.get(&self.owner_agent_id)
.is_some_and(|record| {
normalize_subagent_workspace(&record.spec.workspace)
== normalize_subagent_workspace(&manager.workspace)
});
if shared_checkout && let Some(mailbox) = self.runtime.mailbox.as_ref() {
let _ = mailbox.send(MailboxMessage::FileWrites {
agent_id: self.owner_agent_id.clone(),
turn_seq: 0,
paths: paths.clone(),
});
}
if let Some(record) = manager.worker_records.get_mut(&self.owner_agent_id) {
record.delivery_evidence.observed_writes.extend(paths);
}
@@ -76,6 +76,17 @@ pub enum MailboxMessage {
#[serde(default)]
todo: TodoListSnapshot,
},
/// Workspace files this agent's bounded write tools just changed in the
/// shared checkout. `turn_seq` is the owning engine turn's sequence (the
/// number in its `pre-turn:<seq>` restore point), stamped when the
/// envelope is forwarded; a turn-scoped undo counts these paths as the
/// turn's own.
FileWrites {
agent_id: String,
#[serde(default)]
turn_seq: u64,
paths: Vec<String>,
},
/// Incremental token usage from a sub-agent's API call.
/// Published after each turn so the parent's cost counter updates live.
TokenUsage {
@@ -104,6 +115,7 @@ impl MailboxMessage {
| Self::Progress { agent_id, .. }
| Self::ToolCallStarted { agent_id, .. }
| Self::ToolCallCompleted { agent_id, .. }
| Self::FileWrites { agent_id, .. }
| Self::Completed { agent_id, .. }
| Self::Failed { agent_id, .. }
| Self::Interrupted { agent_id, .. }
@@ -300,6 +300,7 @@ pub(crate) struct UndoRangeInput {
messages: std::sync::Arc<Vec<codewhale_models::Message>>,
boundaries: Vec<usize>,
first_request: usize,
delegated_writes: std::collections::BTreeMap<u64, Vec<String>>,
}
pub(crate) struct UndoRangePlan {
@@ -352,6 +353,17 @@ pub(crate) fn undo_range_input(app: &App, cut: usize) -> anyhow::Result<UndoRang
messages: app.api_messages.clone(),
boundaries,
first_request,
delegated_writes: app
.current_session_id
.as_ref()
.map(|session| {
app.delegated_writes
.iter()
.filter(|((owner, _), _)| owner == session)
.map(|((_, seq), paths)| (*seq, paths.iter().cloned().collect()))
.collect()
})
.unwrap_or_default(),
})
}
@@ -394,6 +406,7 @@ pub(crate) fn plan_undo_range(input: UndoRangeInput) -> anyhow::Result<UndoRange
may_change_files: false,
snapshots: Vec::new(),
declared_writes: Vec::new(),
delegated_writes: Vec::new(),
unrun_tool_calls: Default::default(),
},
);
@@ -460,6 +473,7 @@ pub(crate) fn plan_undo_range(input: UndoRangeInput) -> anyhow::Result<UndoRange
may_change_files: false,
snapshots: Vec::new(),
declared_writes: Vec::new(),
delegated_writes: Vec::new(),
unrun_tool_calls: Default::default(),
},
);
@@ -608,6 +622,11 @@ pub(crate) fn plan_undo_range(input: UndoRangeInput) -> anyhow::Result<UndoRange
may_change_files: true,
snapshots: receipts,
declared_writes,
delegated_writes: input
.delegated_writes
.get(&pre.seq.unwrap_or_default())
.cloned()
.unwrap_or_default(),
unrun_tool_calls: Default::default(),
},
);
+5
View File
@@ -2089,6 +2089,11 @@ pub struct App {
pub agent_progress: HashMap<String, String>,
/// Parent/depth metadata for live progress-only sub-agent rows.
pub agent_progress_meta: HashMap<String, AgentProgressMeta>,
/// Workspace paths the sub-agents of each Engine turn reported writing,
/// keyed by session id and the turn sequence in its `pre-turn:<seq>`
/// restore point. Turn-scoped undo counts them as the turn's own changes.
pub delegated_writes:
std::collections::BTreeMap<(String, u64), std::collections::BTreeSet<String>>,
/// In-transcript sub-agent card index by `agent_id` (issue #128).
/// Maps each live sub-agent to the `HistoryCell::SubAgent` it renders
/// into, so successive mailbox envelopes mutate the same cell rather
+1
View File
@@ -995,6 +995,7 @@ impl App {
subagent_terminal_seen_at: HashMap::new(),
agent_progress: HashMap::new(),
agent_progress_meta: HashMap::new(),
delegated_writes: Default::default(),
subagent_card_index: HashMap::new(),
last_fanout_card_index: None,
pending_subagent_dispatch: None,
+14
View File
@@ -466,6 +466,19 @@ pub(super) fn handle_subagent_mailbox_for_turn(
_seq: u64,
message: &MailboxMessage,
) -> bool {
if let MailboxMessage::FileWrites {
turn_seq, paths, ..
} = message
{
if let Some(session) = app.current_session_id.clone() {
app.delegated_writes
.entry((session, *turn_seq))
.or_default()
.extend(paths.iter().cloned());
}
return false;
}
// Accumulate sub-agent token costs for the real-time footer counter (#166).
if let MailboxMessage::TokenUsage { record, .. } = message {
if let Some(record) = record.as_ref().filter(|record| record.settlement.is_some()) {
@@ -813,6 +826,7 @@ fn record_agent_current_activity(app: &mut App, message: &MailboxMessage) {
previous.as_ref().and_then(|activity| activity.step),
),
MailboxMessage::TokenUsage { .. } => unreachable!("token usage handled above"),
MailboxMessage::FileWrites { .. } => return,
MailboxMessage::WorkState { .. } => unreachable!("work state handled above"),
};
+2 -1
View File
@@ -663,7 +663,7 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool
// to a sibling fanout card, not this one.
return false;
}
MailboxMessage::TokenUsage { .. } => {
MailboxMessage::TokenUsage { .. } | MailboxMessage::FileWrites { .. } => {
// Cost accumulation happens in handle_subagent_mailbox (ui.rs)
// before this apply function is called; TokenUsage never reaches
// this arm in practice.
@@ -720,6 +720,7 @@ pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool {
// this arm in practice.
true
}
MailboxMessage::FileWrites { .. } => false,
}
}