返回 CodeWhale
budget_handback_tests.rs
根目录 / crates / tui / src / tools / subagent / budget_handback_tests.rs
1 use super::tests::{make_worker_spec, stub_runtime};
2 use super::*;
3 use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::post};
4 use tempfile::{TempDir, tempdir};
5 use tokio::sync::Notify;
6
7 struct Fixture {
8 workspace: TempDir,
9 manager: SharedSubAgentManager,
10 task: Option<JoinHandle<()>>,
11 server: JoinHandle<()>,
12 requests: Arc<std::sync::Mutex<Vec<Value>>>,
13 report_started: Arc<Notify>,
14 release_report: Arc<Notify>,
15 cancel: CancellationToken,
16 completions: mpsc::Receiver<SubAgentCompletion>,
17 mailbox: MailboxReceiver,
18 }
19
20 impl Fixture {
21 async fn finish(&mut self) -> SubAgentResult {
22 tokio::time::timeout(Duration::from_secs(5), self.task.take().unwrap())
23 .await
24 .expect("bounded worker")
25 .expect("worker task");
26 self.manager
27 .read()
28 .await
29 .get_result("report-worker")
30 .unwrap()
31 }
32 }
33
34 impl Drop for Fixture {
35 fn drop(&mut self) {
36 if let Some(task) = &self.task {
37 task.abort();
38 }
39 self.server.abort();
40 }
41 }
42
43 async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixture {
44 let workspace = tempdir().unwrap();
45 fs::write(
46 workspace.path().join("README.md"),
47 "TOOL_EVIDENCE: checksum validation is still missing.\n",
48 )
49 .unwrap();
50 let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
51 let report_started = Arc::new(Notify::new());
52 let release_report = Arc::new(Notify::new());
53 let app = Router::new().route("/{*path}", post({
54 let requests = Arc::clone(&requests);
55 let report_started = Arc::clone(&report_started);
56 let release_report = Arc::clone(&release_report);
57 move |Json(body): Json<Value>| {
58 let requests = Arc::clone(&requests);
59 let report_started = Arc::clone(&report_started);
60 let release_report = Arc::clone(&release_report);
61 async move {
62 let call = {
63 let mut requests = requests.lock().unwrap();
64 requests.push(body);
65 requests.len()
66 };
67 let choice = if call == 1 {
68 json!({"index": 0, "message": {"role": "assistant",
69 "content": if mode == "tool-only" { Value::Null } else { json!("RECORDED_FINDING: checksum validation is missing.") },
70 "tool_calls": [{"id": "read-one", "type": "function", "function": {
71 "name": "read", "arguments": "{\"path\":\"README.md\"}"
72 }}]}, "finish_reason": "tool_calls"})
73 } else {
74 report_started.notify_one();
75 if matches!(mode, "hold" | "timeout" | "work-timeout") { release_report.notified().await; }
76 if mode == "failure" {
77 return (StatusCode::BAD_REQUEST, Json(json!({"error": {"message": "fixture rejection"}}))).into_response();
78 }
79 if mode == "tool" {
80 json!({"index": 0, "message": {"role": "assistant", "content": "REJECTED_REPORT: I wrote report.md.",
81 "tool_calls": [{"id": "must-not-write", "type": "function", "function": {
82 "name": "write_file", "arguments": "{\"path\":\"report.md\",\"content\":\"must not execute\"}"
83 }}]}, "finish_reason": "tool_calls"})
84 } else {
85 json!({"index": 0, "message": {"role": "assistant", "content":
86 "PARTIAL_REPORT: README evidence identifies missing checksum validation. No report file was produced. Next: implement and verify the checksum check."}, "finish_reason": "stop"})
87 }
88 };
89 let usage = if (call == 1 && matches!(mode, "unknown" | "resume-unknown")) || (call > 1 && mode == "report-unknown") { Value::Null }
90 else if call == 1 { json!({"prompt_tokens": first_tokens.saturating_sub(5), "completion_tokens": 5, "total_tokens": first_tokens}) }
91 else if mode == "resume-unknown" { json!({"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}) }
92 else { json!({"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}) };
93 Json(json!({"id": format!("handback-{call}"), "model": "deepseek-v4-flash", "choices": [choice], "usage": usage})).into_response()
94 }
95 }
96 }));
97 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
98 let address = listener.local_addr().unwrap();
99 let server = tokio::spawn(async move {
100 axum::serve(listener, app).await.unwrap();
101 });
102 let config = crate::config::Config {
103 api_key: Some("fixture-key".to_string()),
104 base_url: Some(format!("http://{address}/v1")),
105 retry: Some(crate::config::RetryConfig {
106 enabled: Some(false),
107 max_retries: Some(0),
108 initial_delay: Some(0.0),
109 max_delay: Some(0.0),
110 exponential_base: Some(1.0),
111 }),
112 ..Default::default()
113 };
114 let manager = Arc::new(RwLock::new(
115 SubAgentManager::new(workspace.path().to_path_buf(), 4)
116 .with_state_path(workspace.path().join(".codewhale/subagents/state.json")),
117 ));
118 let mut spec = make_worker_spec("report-worker", workspace.path().to_path_buf());
119 spec.max_steps = max_steps;
120 spec.runtime_profile.max_steps = max_steps;
121 spec.runtime_profile.wall_time_secs = Some(5);
122 spec.runtime_profile.wall_deadline_ms = Some(epoch_millis_now() + 5_000);
123 if mode == "work-timeout" {
124 // A partly consumed original deadline leaves time to persist the
125 // missing-coverage receipt after the in-flight call is abandoned.
126 spec.runtime_profile.wall_deadline_ms = Some(epoch_millis_now() + 2_000);
127 }
128 spec.launch_manifest = Some(serde_json::from_value(json!({
129 "owner_session": "root", "child_id": "report-worker", "profile": spec.runtime_profile,
130 "prompt": "Read README.md and produce report.md", "cwd": workspace.path(), "worktree": false,
131 "writable_roots": [], "writable_files": [], "coordination_contracts": [], "deliverables": ["report.md"],
132 "resume_identity": null, "generation": 1, "resume_from_agent_id": null
133 })).unwrap());
134 if mode == "resume-unknown" {
135 spec.launch_manifest.as_mut().unwrap().deliverables.clear();
136 }
137 let mut runtime = stub_runtime();
138 runtime.client = CodewhaleClient::new(&config).unwrap();
139 runtime.api_config = Some(Arc::new(config));
140 runtime.context = ToolContext::new(workspace.path().to_path_buf());
141 runtime.accounting_origin = SubAgentAccountingOrigin::capture(&runtime.context);
142 runtime.manager = Arc::clone(&manager);
143 runtime.worker_profile = spec.runtime_profile.clone();
144 runtime.spawn_depth = spec.spawn_depth;
145 runtime.allow_shell = false;
146 runtime.accept_edits = false;
147 runtime.step_api_timeout = if mode == "timeout" {
148 Duration::from_millis(100)
149 } else {
150 Duration::from_secs(2)
151 };
152 let cancel = runtime.cancel_token.clone();
153 let (parent_tx, completions) = mpsc::channel(16);
154 runtime.parent_completion_tx = Some(parent_tx);
155 let (mailbox, mailbox_rx) = Mailbox::new(CancellationToken::new());
156 runtime.mailbox = Some(mailbox);
157 let assignment = SubAgentAssignment::new(
158 "Read README.md, report findings and identify what remains.".to_string(),
159 None,
160 );
161 let (input_tx, input_rx) = mpsc::unbounded_channel();
162 let mut agent = SubAgent::new(
163 "report-worker".to_string(),
164 FleetRole::Scout,
165 assignment.objective.clone(),
166 assignment.clone(),
167 runtime.model.clone(),
168 None,
169 Some(vec!["read_file".to_string()]),
170 input_tx,
171 workspace.path().to_path_buf(),
172 manager.read().await.current_session_boot_id.clone(),
173 );
174 agent.status = SubAgentStatus::Running;
175 {
176 let mut guard = manager.write().await;
177 guard.register_worker_for_session(spec, &runtime.context.state_namespace, None);
178 guard.agents.insert("report-worker".to_string(), agent);
179 }
180 let task = tokio::spawn(run_subagent_task(SubAgentTask {
181 manager_handle: Arc::clone(&manager),
182 runtime,
183 agent_id: "report-worker".to_string(),
184 agent_type: FleetRole::Scout,
185 prompt: assignment.objective.clone(),
186 assignment,
187 allowed_tools: Some(vec!["read_file".to_string()]),
188 fork_context: false,
189 started_at: Instant::now(),
190 max_steps,
191 wall_time: Duration::from_secs(5),
192 input_rx,
193 launch_gate: None,
194 _foreground_child_registration: None,
195 }));
196 Fixture {
197 workspace,
198 manager,
199 task: Some(task),
200 server,
201 requests,
202 report_started,
203 release_report,
204 cancel,
205 completions,
206 mailbox: mailbox_rx,
207 }
208 }
209
210 #[tokio::test]
211 #[allow(clippy::await_holding_lock)]
212 async fn budget_handback_turn_consolidates_tool_only_work_and_checks_declared_deliverables() {
213 let _retry = crate::retry_status::test_guard();
214 crate::retry_status::clear_rate_limit();
215 let mut fixture = fixture("tool-only", 15, 1).await;
216 let result = fixture.finish().await;
217 assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
218 assert_eq!(result.steps_taken, 2);
219 assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(45));
220 assert!(result.result.as_deref().unwrap().contains("PARTIAL_REPORT"));
221 assert!(
222 result
223 .checkpoint
224 .as_ref()
225 .unwrap()
226 .messages
227 .iter()
228 .flat_map(|message| &message.content)
229 .any(
230 |block| matches!(block, ContentBlock::ToolResult { tool_use_id, content, .. }
231 if tool_use_id == "read-one" && content.contains("TOOL_EVIDENCE"))
232 ),
233 "the read must execute successfully before reporting: {:?}",
234 result.checkpoint,
235 );
236 let requests = fixture.requests.lock().unwrap();
237 assert_eq!(requests.len(), 2);
238 assert_eq!(requests[0]["model"], "deepseek-v4-flash");
239 assert!(
240 requests[0]["tools"]
241 .as_array()
242 .unwrap()
243 .iter()
244 .any(|tool| tool["function"]["name"] == "read")
245 );
246 assert_eq!(requests[1]["model"], requests[0]["model"]);
247 assert!(requests[1].get("tools").is_none_or(Value::is_null));
248 assert!(requests[1].get("tool_choice").is_none_or(Value::is_null));
249 assert!(
250 requests[1].to_string().contains("TOOL_EVIDENCE"),
251 "report must read actual completed tool output"
252 );
253 assert!(
254 requests[1]["max_tokens"]
255 .as_u64()
256 .or_else(|| requests[1]["max_completion_tokens"].as_u64())
257 .unwrap()
258 <= 1_024
259 );
260 drop(requests);
261 let guard = fixture.manager.read().await;
262 let worker = &guard.worker_records["report-worker"];
263 assert_eq!(worker.verification.status, "deliverable_missing");
264 assert_eq!(worker.verification.deliverables[0].path, "report.md");
265 assert!(
266 !worker.spec.runtime_profile.permissions.write,
267 "report did not widen the Scout's authority"
268 );
269 drop(guard);
270 let completion = fixture.completions.try_recv().unwrap();
271 assert!(completion.payload.contains("budget_exhausted"));
272 assert!(completion.payload.contains("deliverable_missing"));
273 assert!(fixture.completions.try_recv().is_err());
274 }
275
276 #[tokio::test]
277 #[allow(clippy::await_holding_lock)]
278 async fn budget_handback_turn_rejects_provider_tools_and_preserves_fallback_verdicts() {
279 let _retry = crate::retry_status::test_guard();
280 crate::retry_status::clear_rate_limit();
281 let mut fixture = fixture("tool", 15, 1).await;
282 let result = fixture.finish().await;
283 assert_eq!(fixture.requests.lock().unwrap().len(), 2);
284 assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
285 assert!(!fixture.workspace.path().join("report.md").exists());
286 assert!(
287 result
288 .result
289 .as_deref()
290 .unwrap()
291 .contains("provider returned a tool call")
292 );
293 assert!(
294 !result
295 .result
296 .as_deref()
297 .unwrap()
298 .contains("REJECTED_REPORT")
299 );
300 assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(45));
301 let history = &result.checkpoint.as_ref().unwrap().messages;
302 let completed = history
303 .iter()
304 .flat_map(|message| &message.content)
305 .filter_map(|block| match block {
306 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
307 _ => None,
308 })
309 .collect::<HashSet<_>>();
310 for block in history.iter().flat_map(|message| &message.content) {
311 match block {
312 ContentBlock::ToolUse { id, .. } => assert!(
313 completed.contains(id.as_str()),
314 "no orphan tool call may survive for replay"
315 ),
316 ContentBlock::ServerToolUse { .. } => {
317 panic!("a rejected server tool call entered history")
318 }
319 ContentBlock::Text { text, .. } => assert!(!text.contains("REJECTED_REPORT")),
320 _ => {}
321 }
322 }
323 assert!(
324 history
325 .iter()
326 .flat_map(|message| &message.content)
327 .any(|block| matches!(block,
328 ContentBlock::Text { text, .. } if text.contains("Host budget hand-back receipt")))
329 );
330 assert_eq!(
331 fixture.manager.read().await.worker_records["report-worker"]
332 .verification
333 .status,
334 "deliverable_missing"
335 );
336 }
337
338 #[tokio::test]
339 #[allow(clippy::await_holding_lock)]
340 async fn budget_handback_turn_failure_and_timeout_do_not_add_worker_retries() {
341 let _retry = crate::retry_status::test_guard();
342 crate::retry_status::clear_rate_limit();
343 for (mode, reason) in [
344 ("failure", "provider call failed"),
345 ("timeout", "report deadline expired"),
346 ] {
347 let mut fixture = fixture(mode, 15, 1).await;
348 let result = fixture.finish().await;
349 assert_eq!(fixture.requests.lock().unwrap().len(), 2);
350 assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
351 assert!(
352 result.result.as_deref().unwrap().contains(reason),
353 "{result:?}"
354 );
355 assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(15));
356 assert_eq!(
357 fixture.manager.read().await.worker_records["report-worker"].has_unreported_usage,
358 mode == "timeout",
359 "only the timed-out dispatched call establishes missing coverage here",
360 );
361 assert_eq!(
362 fixture.manager.read().await.worker_records["report-worker"]
363 .verification
364 .status,
365 "deliverable_missing"
366 );
367 }
368 }
369
370 #[tokio::test]
371 #[allow(clippy::await_holding_lock)]
372 async fn budget_handback_turn_missing_report_usage_is_not_claimed_as_zero_cost() {
373 let _retry = crate::retry_status::test_guard();
374 crate::retry_status::clear_rate_limit();
375 let mut fixture = fixture("report-unknown", 15, 1).await;
376 let result = fixture.finish().await;
377 assert_eq!(fixture.requests.lock().unwrap().len(), 2);
378 assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
379 assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(15));
380 let report = result.result.as_deref().unwrap();
381 assert!(report.contains("PARTIAL_REPORT"));
382 assert!(report.contains("only a subtotal, not a zero-cost report"));
383 }
384
385 #[tokio::test]
386 #[allow(clippy::await_holding_lock)]
387 async fn budget_handback_inflight_wall_timeout_persists_unreported_usage() {
388 let _retry = crate::retry_status::test_guard();
389 crate::retry_status::clear_rate_limit();
390 let mut fixture = fixture("work-timeout", 15, 4).await;
391 let result = fixture.finish().await;
392 assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
393 assert!(
394 result
395 .result
396 .as_deref()
397 .unwrap()
398 .contains("wall-time budget exhausted during a model request")
399 );
400 assert_eq!(
401 fixture.requests.lock().unwrap().len(),
402 3,
403 "the reserved hand-back turn still dispatches after an unmeasured in-flight request; its own deadline bounds it"
404 );
405 let manager = fixture.manager.read().await;
406 assert!(manager.worker_records["report-worker"].has_unreported_usage);
407 assert_eq!(
408 manager.worker_records["report-worker"].usage.total_tokens,
409 Some(15)
410 );
411 }
412
413 #[test]
414 fn budget_handback_coverage_marker_is_sticky_without_reclassifying_legacy_or_measured_zero() {
415 let tmp = tempdir().unwrap();
416 let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4);
417 for id in ["worker", "unrelated"] {
418 let spec = make_worker_spec(id, tmp.path().to_path_buf());
419 manager.register_worker(spec);
420 }
421 let measured_zero = Usage {
422 prompt_cache_hit_tokens: Some(0),
423 ..Usage::default()
424 };
425 manager.record_worker_usage("worker", "zero", &measured_zero, None);
426 manager.record_worker_usage("unrelated", "unrelated-missing", &Usage::default(), None);
427 assert_eq!(manager.worker_records["worker"].usage.total_tokens, Some(0));
428 assert!(!manager.worker_records["worker"].has_unreported_usage);
429 let mut legacy = serde_json::to_value(&manager.worker_records["worker"]).unwrap();
430 legacy
431 .as_object_mut()
432 .unwrap()
433 .remove("has_unreported_usage");
434 let legacy: AgentWorkerRecord = serde_json::from_value(legacy).unwrap();
435 assert!(!legacy.has_unreported_usage);
436 manager.worker_records.insert("worker".to_string(), legacy);
437 let (_output, lease) = manager.reserve_handback("worker", 500, 1_024).unwrap();
438 drop(lease);
439 manager.record_worker_usage("worker", "missing", &Usage::default(), None);
440 manager.record_worker_usage(
441 "worker",
442 "known-later",
443 &Usage {
444 input_tokens: 10,
445 output_tokens: 5,
446 ..Usage::default()
447 },
448 None,
449 );
450 let saved = serde_json::to_vec(&manager.worker_records).unwrap();
451 manager.worker_records = serde_json::from_slice(&saved).unwrap();
452 assert_eq!(
453 manager.worker_records["worker"].usage.total_tokens,
454 Some(15)
455 );
456 assert!(manager.worker_records["worker"].has_unreported_usage);
457 }
458
459 #[tokio::test]
460 #[allow(clippy::await_holding_lock)]
461 async fn budget_handback_turn_cancellation_wins_once_and_releases_shared_reservation() {
462 let _retry = crate::retry_status::test_guard();
463 crate::retry_status::clear_rate_limit();
464 let mut fixture = fixture("hold", 15, 2).await;
465 tokio::time::timeout(Duration::from_secs(2), fixture.report_started.notified())
466 .await
467 .unwrap();
468 fixture.cancel.cancel();
469 let result = fixture.finish().await;
470 assert_eq!(result.status, SubAgentStatus::Cancelled);
471 assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(15));
472 assert!(fixture.manager.read().await.worker_records["report-worker"].has_unreported_usage);
473 assert!(
474 fixture
475 .completions
476 .try_recv()
477 .unwrap()
478 .payload
479 .contains("cancelled")
480 );
481 assert!(fixture.completions.try_recv().is_err());
482 assert!(
483 fixture
484 .manager
485 .read()
486 .await
487 .handback_reservations
488 .values()
489 .all(|value| value.upgrade().is_none())
490 );
491 }
492
493 #[tokio::test]
494 #[allow(clippy::await_holding_lock)]
495 async fn budget_handback_turn_cancellation_after_response_preserves_actual_usage() {
496 let _retry = crate::retry_status::test_guard();
497 crate::retry_status::clear_rate_limit();
498 let mut fixture = fixture("hold", 15, 2).await;
499 tokio::time::timeout(Duration::from_secs(2), fixture.report_started.notified())
500 .await
501 .unwrap();
502 let manager = Arc::clone(&fixture.manager);
503 let guard = manager.write().await;
504 fixture.release_report.notify_one();
505 // Runtime billing publishes the decoded response before it waits for the
506 // worker ledger lock. This makes the cancellation seam deterministic.
507 tokio::time::timeout(Duration::from_secs(2), async {
508 loop {
509 let entry = fixture.mailbox.recv().await.unwrap();
510 if matches!(entry.message, MailboxMessage::TokenUsage { ref source_id, .. } if source_id.contains(":handback:")) { break; }
511 }
512 }).await.unwrap();
513 fixture.cancel.cancel();
514 drop(guard);
515 let result = fixture.finish().await;
516 assert_eq!(result.status, SubAgentStatus::Cancelled);
517 assert_eq!(result.usage.as_ref().unwrap().total_tokens, Some(45));
518 assert!(!fixture.manager.read().await.worker_records["report-worker"].has_unreported_usage);
519 assert!(
520 fixture
521 .completions
522 .try_recv()
523 .unwrap()
524 .payload
525 .contains("cancelled")
526 );
527 assert!(fixture.completions.try_recv().is_err());
528 }
529
530 #[test]
531 fn handback_reservation_uses_the_fixed_allowance_and_refuses_a_second_turn() {
532 let tmp = tempdir().unwrap();
533 let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4);
534 manager.register_worker(make_worker_spec("w", tmp.path().to_path_buf()));
535 let (output, lease) = manager.reserve_handback("w", 500, 1_024).unwrap();
536 assert_eq!(output, 1_024);
537 assert!(matches!(
538 manager.reserve_handback("w", 500, 1_024),
539 Err(reason) if reason.contains("already in flight")
540 ));
541 drop(lease);
542 assert!(matches!(
543 manager.reserve_handback("w", 8_200, 1_024),
544 Err(reason) if reason.contains("fixed hand-back allowance")
545 ));
546 }
547
548 #[tokio::test]
549 async fn budget_handback_expired_original_deadline_refuses_the_model_call() {
550 let tmp = tempdir().unwrap();
551 let mut runtime = stub_runtime();
552 runtime.context = ToolContext::new(tmp.path().to_path_buf());
553 runtime.manager = Arc::new(RwLock::new(SubAgentManager::new(
554 tmp.path().to_path_buf(),
555 1,
556 )));
557 runtime
558 .manager
559 .write()
560 .await
561 .register_worker(make_worker_spec("expired", tmp.path().to_path_buf()));
562 let mut steps = 1;
563 let outcome = budget_handback::request_report(
564 &runtime,
565 "expired",
566 &SubAgentAssignment::new("report".to_string(), None),
567 &mut vec![],
568 &mut steps,
569 2,
570 Some(Instant::now() - Duration::from_millis(1)),
571 "wall-time budget exhausted",
572 )
573 .await;
574 assert!(
575 matches!(outcome, budget_handback::Outcome::Fallback(ref why) if why.contains("deadline has expired"))
576 );
577 assert_eq!(steps, 1, "no model turn was admitted");
578 assert!(!runtime.manager.read().await.worker_records["expired"].has_unreported_usage);
579 assert!(
580 runtime
581 .manager
582 .read()
583 .await
584 .handback_reservations
585 .is_empty()
586 );
587 }
588
589 fn git(root: &Path, args: &[&str]) {
590 let output = std::process::Command::new("git")
591 .arg("-C")
592 .arg(root)
593 .args(args)
594 .output()
595 .expect("git");
596 assert!(
597 output.status.success(),
598 "git {args:?}: {}",
599 String::from_utf8_lossy(&output.stderr)
600 );
601 }
602
603 /// #5529: a budget death must name the work the worker left on disk. The
604 /// spawn-time delivery baseline is what makes the inventory attributable to
605 /// this worker rather than the parent's own dirty files.
606 #[tokio::test]
607 async fn run_death_preservation_note_names_surviving_workspace_changes() {
608 let tmp = tempdir().unwrap();
609 let root = tmp.path();
610 git(root, &["init", "--quiet"]);
611 git(root, &["config", "user.name", "Budget test"]);
612 git(root, &["config", "user.email", "budget@example.invalid"]);
613 fs::write(root.join("src.rs"), "baseline\n").unwrap();
614 git(root, &["add", "--", "src.rs"]);
615 git(root, &["commit", "--quiet", "-m", "baseline"]);
616
617 let manager = Arc::new(RwLock::new(SubAgentManager::new(root.to_path_buf(), 2)));
618 let mut spec = make_worker_spec("preserve-worker", root.to_path_buf());
619 spec.runtime_profile.permissions.write = true;
620 manager.write().await.register_worker(spec);
621
622 // The worker's unfinished work lands after the baseline was captured.
623 fs::create_dir_all(root.join("scratch")).unwrap();
624 fs::write(root.join("scratch/leftover.rs"), "wip\n").unwrap();
625
626 let mut runtime = stub_runtime();
627 runtime.manager = Arc::clone(&manager);
628
629 let note = budget_work_preservation_note(&runtime, "preserve-worker", "wall_time_budget")
630 .await
631 .expect("write-scoped worker has a baseline");
632 assert!(
633 note.contains("scratch/leftover.rs"),
634 "note should name the surviving path: {note}"
635 );
636 assert!(note.contains(&root.display().to_string()), "{note}");
637
638 // A read-only worker captured no baseline — there is no file work to
639 // inventory and the note stays absent rather than lying.
640 let mut scout_spec = make_worker_spec("scout-worker", root.to_path_buf());
641 scout_spec.runtime_profile.permissions.write = false;
642 manager.write().await.register_worker(scout_spec);
643 assert!(
644 budget_work_preservation_note(&runtime, "scout-worker", "wall_time_budget")
645 .await
646 .is_none()
647 );
648
649 // A write-scoped worker that changed nothing still gets an explicit
650 // "no changes" receipt instead of silence.
651 let mut clean_spec = make_worker_spec("clean-worker", root.to_path_buf());
652 clean_spec.runtime_profile.permissions.write = true;
653 clean_spec.workspace = root.to_path_buf();
654 let clean_root = tempdir().unwrap();
655 let clean_path = clean_root.path();
656 git(clean_path, &["init", "--quiet"]);
657 git(clean_path, &["config", "user.name", "Budget test"]);
658 git(
659 clean_path,
660 &["config", "user.email", "budget@example.invalid"],
661 );
662 fs::write(clean_path.join("src.rs"), "baseline\n").unwrap();
663 git(clean_path, &["add", "--", "src.rs"]);
664 git(clean_path, &["commit", "--quiet", "-m", "baseline"]);
665 clean_spec.workspace = clean_path.to_path_buf();
666 manager.write().await.register_worker(clean_spec);
667 let note = budget_work_preservation_note(&runtime, "clean-worker", "wall_time_budget")
668 .await
669 .expect("baseline exists");
670 assert!(note.contains("No workspace changes"), "{note}");
671 }
672
673 fn git_out(root: &Path, args: &[&str]) -> String {
674 let output = std::process::Command::new("git")
675 .arg("-C")
676 .arg(root)
677 .args(args)
678 .output()
679 .expect("git");
680 assert!(
681 output.status.success(),
682 "git {args:?}: {}",
683 String::from_utf8_lossy(&output.stderr)
684 );
685 String::from_utf8_lossy(&output.stdout).trim().to_string()
686 }
687
688 /// #6194 item 4 / #5529: on an isolated worktree a budget death commits the
689 /// worker's uncommitted changes as labeled salvage instead of leaving them
690 /// for manual recovery.
691 #[tokio::test]
692 async fn budget_death_checkpoint_commits_uncommitted_work_on_isolated_worktree() {
693 let tmp = tempdir().unwrap();
694 let root = tmp.path();
695 git(root, &["init", "--quiet"]);
696 git(root, &["config", "user.name", "Budget test"]);
697 git(root, &["config", "user.email", "budget@example.invalid"]);
698 fs::write(root.join("src.rs"), "baseline\n").unwrap();
699 git(root, &["add", "--", "src.rs"]);
700 git(root, &["commit", "--quiet", "-m", "baseline"]);
701
702 let manager = Arc::new(RwLock::new(SubAgentManager::new(root.to_path_buf(), 2)));
703 let mut spec = make_worker_spec("checkpoint-worker", root.to_path_buf());
704 spec.runtime_profile.permissions.write = true;
705 spec.launch_manifest = Some(ChildLaunchManifest {
706 owner_session: "root".to_string(),
707 child_id: "checkpoint-worker".to_string(),
708 profile: spec.runtime_profile.clone(),
709 prompt: spec.objective.clone(),
710 cwd: Some(root.display().to_string()),
711 worktree: true,
712 writable_roots: vec![root.display().to_string()],
713 writable_files: Vec::new(),
714 coordination_contracts: Vec::new(),
715 expected_artifact: None,
716 deliverables: Vec::new(),
717 resume_identity: None,
718 generation: 1,
719 resume_from_agent_id: None,
720 });
721 manager.write().await.register_worker(spec);
722 fs::write(root.join("src.rs"), "baseline\nuncommitted fix\n").unwrap();
723 fs::write(root.join("new.rs"), "wip\n").unwrap();
724
725 let mut runtime = stub_runtime();
726 runtime.manager = Arc::clone(&manager);
727 let note = budget_work_preservation_note(&runtime, "checkpoint-worker", "wall_time_budget")
728 .await
729 .expect("note");
730 assert!(
731 note.contains("checkpointed in commit"),
732 "note should name the salvage commit: {note}"
733 );
734 let subject = git_out(root, &["log", "--format=%s", "-1"]);
735 assert!(
736 subject.starts_with("checkpoint: checkpoint-worker (wall_time_budget)"),
737 "marker message names the worker and cause: {subject}"
738 );
739 assert!(
740 git_out(root, &["status", "--porcelain=v1", "--"]).is_empty(),
741 "checkpoint leaves a clean tree"
742 );
743 }
744
745 /// A shared checkout may hold the parent's or a sibling's dirty files, so no
746 /// auto-commit happens there — the note keeps the manual-salvage wording.
747 #[tokio::test]
748 async fn budget_death_checkpoint_skips_shared_checkout() {
749 let tmp = tempdir().unwrap();
750 let root = tmp.path();
751 git(root, &["init", "--quiet"]);
752 git(root, &["config", "user.name", "Budget test"]);
753 git(root, &["config", "user.email", "budget@example.invalid"]);
754 fs::write(root.join("src.rs"), "baseline\n").unwrap();
755 git(root, &["add", "--", "src.rs"]);
756 git(root, &["commit", "--quiet", "-m", "baseline"]);
757
758 let manager = Arc::new(RwLock::new(SubAgentManager::new(root.to_path_buf(), 2)));
759 let mut spec = make_worker_spec("shared-worker", root.to_path_buf());
760 spec.runtime_profile.permissions.write = true;
761 manager.write().await.register_worker(spec);
762 fs::write(root.join("src.rs"), "baseline\nuncommitted fix\n").unwrap();
763
764 let mut runtime = stub_runtime();
765 runtime.manager = Arc::clone(&manager);
766 let note = budget_work_preservation_note(&runtime, "shared-worker", "wall_time_budget")
767 .await
768 .expect("note");
769 assert!(!note.contains("checkpointed in commit"), "{note}");
770 assert!(note.contains("survive on disk"), "{note}");
771 assert!(
772 !git_out(root, &["status", "--porcelain=v1", "--"]).is_empty(),
773 "shared checkout stays dirty"
774 );
775 }
776
777 /// When the worker committed everything itself before death, the note says so
778 /// instead of claiming a checkpoint or manual salvage.
779 #[tokio::test]
780 async fn budget_death_checkpoint_reports_worker_committed_tree() {
781 let tmp = tempdir().unwrap();
782 let root = tmp.path();
783 git(root, &["init", "--quiet"]);
784 git(root, &["config", "user.name", "Budget test"]);
785 git(root, &["config", "user.email", "budget@example.invalid"]);
786 fs::write(root.join("src.rs"), "baseline\n").unwrap();
787 git(root, &["add", "--", "src.rs"]);
788 git(root, &["commit", "--quiet", "-m", "baseline"]);
789
790 let manager = Arc::new(RwLock::new(SubAgentManager::new(root.to_path_buf(), 2)));
791 let mut spec = make_worker_spec("tidy-worker", root.to_path_buf());
792 spec.runtime_profile.permissions.write = true;
793 spec.launch_manifest = Some(ChildLaunchManifest {
794 owner_session: "root".to_string(),
795 child_id: "tidy-worker".to_string(),
796 profile: spec.runtime_profile.clone(),
797 prompt: spec.objective.clone(),
798 cwd: Some(root.display().to_string()),
799 worktree: true,
800 writable_roots: vec![root.display().to_string()],
801 writable_files: Vec::new(),
802 coordination_contracts: Vec::new(),
803 expected_artifact: None,
804 deliverables: Vec::new(),
805 resume_identity: None,
806 generation: 1,
807 resume_from_agent_id: None,
808 });
809 manager.write().await.register_worker(spec);
810 fs::write(root.join("src.rs"), "baseline\nworker fix\n").unwrap();
811 git(root, &["add", "--", "src.rs"]);
812 git(root, &["commit", "--quiet", "-m", "worker fix"]);
813
814 let mut runtime = stub_runtime();
815 runtime.manager = Arc::clone(&manager);
816 let note = budget_work_preservation_note(&runtime, "tidy-worker", "wall_time_budget")
817 .await
818 .expect("note");
819 assert!(note.contains("committed before death"), "{note}");
820 }
821
822 fn assistant_message(content: Vec<ContentBlock>) -> Message {
823 Message {
824 role: Role::Assistant,
825 content,
826 }
827 }
828
829 fn tool_use(name: &str, input: Value) -> ContentBlock {
830 ContentBlock::ToolUse {
831 id: format!("call_{name}"),
832 name: name.to_string(),
833 input,
834 caller: None,
835 thought_signature: None,
836 }
837 }
838
839 #[test]
840 fn fallback_partial_text_prefers_last_assistant_text() {
841 let messages = vec![
842 assistant_message(vec![ContentBlock::Text {
843 text: "first".to_string(),
844 cache_control: None,
845 }]),
846 assistant_message(vec![
847 tool_use("Read", json!({"path": "src/main.rs"})),
848 ContentBlock::Text {
849 text: "second".to_string(),
850 cache_control: None,
851 },
852 ]),
853 ];
854 assert_eq!(budget_handback::fallback_partial_text(&messages), "second");
855 }
856
857 #[test]
858 fn fallback_partial_text_digests_thinking_and_tool_calls_without_text() {
859 let messages = vec![
860 assistant_message(vec![ContentBlock::Thinking {
861 thinking: "checking whether the ring slot write precedes the read".to_string(),
862 signature: None,
863 state: None,
864 }]),
865 assistant_message(vec![tool_use("Read", json!({"path": "ring.rs"}))]),
866 assistant_message(vec![tool_use(
867 "Grep",
868 json!({"pattern": "slot", "path": "ring.rs"}),
869 )]),
870 ];
871 let digest = budget_handback::fallback_partial_text(&messages);
872 assert!(digest.contains("Tool calls (newest first)"), "{digest}");
873 assert!(digest.contains("- Grep ring.rs"), "{digest}");
874 assert!(digest.contains("- Read ring.rs"), "{digest}");
875 assert!(
876 digest.find("- Grep").unwrap() < digest.find("- Read").unwrap(),
877 "{digest}"
878 );
879 assert!(digest.contains("unverified"), "{digest}");
880 assert!(
881 digest.contains("ring slot write precedes the read"),
882 "{digest}"
883 );
884 }
885
886 #[test]
887 fn fallback_partial_text_caps_tool_entries_and_reports_overflow() {
888 let messages: Vec<Message> = (0..14)
889 .map(|i| {
890 assistant_message(vec![tool_use(
891 "Read",
892 json!({"path": format!("file_{i}.rs")}),
893 )])
894 })
895 .collect();
896 let digest = budget_handback::fallback_partial_text(&messages);
897 assert!(digest.contains("...and 2 more"), "{digest}");
898 assert!(!digest.contains("file_0.rs"), "{digest}");
899 assert!(digest.contains("file_13.rs"), "{digest}");
900 }
901
902 #[test]
903 fn fallback_partial_text_is_silent_only_when_nothing_was_recorded() {
904 assert!(budget_handback::fallback_partial_text(&[]).contains("No assistant text was recorded"));
905 let user_only = vec![Message {
906 role: Role::User,
907 content: vec![ContentBlock::Text {
908 text: "do the thing".to_string(),
909 cache_control: None,
910 }],
911 }];
912 assert!(
913 budget_handback::fallback_partial_text(&user_only)
914 .contains("No assistant text was recorded")
915 );
916 }
917
917 lines RUST