返回 CodeWhale
verifier.rs
根目录 / crates / tui / src / tools / verifier.rs
1 //! Parallel verifier ensemble tool: `run_verifiers`.
2 //!
3 //! This is the agent-facing path for "parallelize the verifier, not the
4 //! generator": one tool call fans out to independent project checks across
5 //! common ecosystems and returns a single structured verdict.
6
7 use std::collections::{BTreeSet, HashMap};
8 use std::fs;
9 use std::path::{Path, PathBuf};
10 use std::process::{Command, Stdio};
11 use std::time::Instant;
12
13 use async_trait::async_trait;
14 use serde::{Deserialize, Serialize};
15 use serde_json::{Value, json};
16 use shlex::try_join;
17
18 use crate::dependencies::ExternalTool;
19
20 use super::spec::{
21 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
22 };
23
24 const MAX_GATE_OUTPUT_CHARS: usize = 16_000;
25 const DEFAULT_MAX_PYTHON_FILES: usize = 200;
26 const MAX_CUSTOM_GATES: usize = 12;
27 const BACKGROUND_GATE_TIMEOUT_MS: u64 = 600_000;
28
29 /// Tool for running independent verifier gates concurrently.
30 pub struct RunVerifiersTool;
31
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33 #[serde(rename_all = "snake_case")]
34 enum VerifierProfile {
35 Auto,
36 Rust,
37 Node,
38 Python,
39 Go,
40 }
41
42 impl VerifierProfile {
43 fn parse(raw: &str) -> Result<Self, ToolError> {
44 match raw {
45 "auto" => Ok(Self::Auto),
46 "rust" => Ok(Self::Rust),
47 "node" => Ok(Self::Node),
48 "python" => Ok(Self::Python),
49 "go" => Ok(Self::Go),
50 other => Err(ToolError::invalid_input(format!(
51 "Unsupported profile '{other}'. Expected one of: auto, rust, node, python, go"
52 ))),
53 }
54 }
55
56 fn as_str(self) -> &'static str {
57 match self {
58 Self::Auto => "auto",
59 Self::Rust => "rust",
60 Self::Node => "node",
61 Self::Python => "python",
62 Self::Go => "go",
63 }
64 }
65 }
66
67 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
68 #[serde(rename_all = "snake_case")]
69 enum VerifierLevel {
70 Quick,
71 Full,
72 }
73
74 impl VerifierLevel {
75 fn parse(raw: &str) -> Result<Self, ToolError> {
76 match raw {
77 "quick" => Ok(Self::Quick),
78 "full" => Ok(Self::Full),
79 other => Err(ToolError::invalid_input(format!(
80 "Unsupported level '{other}'. Expected one of: quick, full"
81 ))),
82 }
83 }
84
85 fn as_str(self) -> &'static str {
86 match self {
87 Self::Quick => "quick",
88 Self::Full => "full",
89 }
90 }
91 }
92
93 #[derive(Debug, Clone, Deserialize)]
94 #[serde(default, deny_unknown_fields)]
95 struct RunVerifiersInput {
96 profile: String,
97 level: String,
98 max_python_files: usize,
99 commands: Vec<CustomVerifierInput>,
100 background: bool,
101 }
102
103 impl Default for RunVerifiersInput {
104 fn default() -> Self {
105 Self {
106 profile: "auto".to_string(),
107 level: "quick".to_string(),
108 max_python_files: DEFAULT_MAX_PYTHON_FILES,
109 commands: Vec::new(),
110 background: false,
111 }
112 }
113 }
114
115 #[derive(Debug, Clone, Default, Deserialize)]
116 #[serde(default, deny_unknown_fields)]
117 struct CustomVerifierInput {
118 name: String,
119 program: String,
120 args: Vec<String>,
121 cwd: Option<String>,
122 }
123
124 #[derive(Debug, Clone)]
125 struct VerifierGate {
126 name: String,
127 ecosystem: String,
128 cwd: PathBuf,
129 program: Option<String>,
130 args: Vec<String>,
131 env: Vec<(String, String)>,
132 skipped_reason: Option<String>,
133 }
134
135 #[derive(Debug, Clone, Serialize, Deserialize)]
136 struct GateResult {
137 name: String,
138 ecosystem: String,
139 status: GateStatus,
140 command: String,
141 cwd: String,
142 exit_code: Option<i32>,
143 duration_ms: u64,
144 stdout: String,
145 stderr: String,
146 stdout_truncated: bool,
147 stderr_truncated: bool,
148 skipped_reason: Option<String>,
149 }
150
151 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152 #[serde(rename_all = "snake_case")]
153 enum GateStatus {
154 Passed,
155 Failed,
156 Skipped,
157 }
158
159 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160 #[serde(rename_all = "snake_case")]
161 enum VerifierVerdict {
162 Pass,
163 Partial,
164 Fail,
165 }
166
167 impl VerifierVerdict {
168 fn from_counts(gate_count: usize, failed: usize, skipped: usize) -> Self {
169 if failed > 0 {
170 Self::Fail
171 } else if skipped > 0 || gate_count == 0 {
172 Self::Partial
173 } else {
174 Self::Pass
175 }
176 }
177
178 fn hunt_verdict(self) -> &'static str {
179 match self {
180 Self::Pass => "hunted",
181 Self::Partial => "wounded",
182 Self::Fail => "escaped",
183 }
184 }
185
186 fn goal_status(self) -> &'static str {
187 match self {
188 Self::Pass => "complete",
189 Self::Partial => "paused",
190 Self::Fail => "blocked",
191 }
192 }
193 }
194
195 #[derive(Debug, Clone, Serialize, Deserialize)]
196 struct RunVerifiersOutput {
197 success: bool,
198 profile: String,
199 level: String,
200 workspace: String,
201 gate_count: usize,
202 passed: usize,
203 failed: usize,
204 skipped: usize,
205 verifier_verdict: VerifierVerdict,
206 hunt_verdict: String,
207 goal_status: String,
208 summary: String,
209 gates: Vec<GateResult>,
210 }
211
212 #[derive(Debug, Clone, Serialize, Deserialize)]
213 struct BackgroundGateJob {
214 name: String,
215 ecosystem: String,
216 status: String,
217 command: String,
218 cwd: String,
219 task_id: Option<String>,
220 skipped_reason: Option<String>,
221 error: Option<String>,
222 }
223
224 #[derive(Debug, Clone, Serialize, Deserialize)]
225 struct RunVerifiersBackgroundOutput {
226 success: bool,
227 profile: String,
228 level: String,
229 workspace: String,
230 background: bool,
231 gate_count: usize,
232 started: usize,
233 skipped: usize,
234 failed_to_start: usize,
235 summary: String,
236 jobs: Vec<BackgroundGateJob>,
237 }
238
239 #[async_trait]
240 impl ToolSpec for RunVerifiersTool {
241 fn name(&self) -> &'static str {
242 "run_verifiers"
243 }
244
245 fn model_visible(&self) -> bool {
246 false
247 }
248
249 fn description(&self) -> &'static str {
250 "Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects. Supports explicit custom verifier commands as program+args without requiring Bash."
251 }
252
253 fn input_schema(&self) -> Value {
254 json!({
255 "type": "object",
256 "properties": {
257 "profile": {
258 "type": "string",
259 "enum": ["auto", "rust", "node", "python", "go"],
260 "default": "auto",
261 "description": "Which ecosystem verifier set to run. 'auto' detects all supported project types in the workspace."
262 },
263 "level": {
264 "type": "string",
265 "enum": ["quick", "full"],
266 "default": "quick",
267 "description": "Quick runs fast syntax/drift/build checks. Full adds heavier test/lint gates where available."
268 },
269 "max_python_files": {
270 "type": "integer",
271 "minimum": 1,
272 "maximum": 1000,
273 "default": DEFAULT_MAX_PYTHON_FILES,
274 "description": "Maximum Python files to syntax-parse in the built-in python-syntax gate."
275 },
276 "commands": {
277 "type": "array",
278 "description": "Optional explicit verifier gates. Commands run directly as program+args, not through a shell. Use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.",
279 "items": {
280 "type": "object",
281 "properties": {
282 "name": {
283 "type": "string",
284 "description": "Short unique gate name."
285 },
286 "program": {
287 "type": "string",
288 "description": "Executable to spawn, for example 'uv', 'pytest', 'npm', 'make', 'cmd', 'powershell', or 'bash'."
289 },
290 "args": {
291 "type": "array",
292 "items": { "type": "string" },
293 "description": "Arguments passed directly to the executable."
294 },
295 "cwd": {
296 "type": "string",
297 "description": "Optional working directory relative to the workspace."
298 }
299 },
300 "required": ["name", "program"],
301 "additionalProperties": false
302 },
303 },
304 "background": {
305 "type": "boolean",
306 "default": false,
307 "description": "Start verifier gates as background shell jobs and return task_ids immediately. Use for long build/test/lint gates; completion is tracked in task/status state, and `Bash` with action 'wait' / task_shell_wait are only for early output, final output, or true dependency barriers."
308 }
309 },
310 "additionalProperties": false
311 })
312 }
313
314 fn capabilities(&self) -> Vec<ToolCapability> {
315 vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable]
316 }
317
318 fn approval_requirement(&self) -> ApprovalRequirement {
319 ApprovalRequirement::Required
320 }
321
322 fn starts_detached_for(&self, input: &Value) -> bool {
323 input.get("background").and_then(Value::as_bool) == Some(true)
324 }
325
326 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
327 let input: RunVerifiersInput = serde_json::from_value(input)
328 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
329 let profile = VerifierProfile::parse(input.profile.as_str())?;
330 let level = VerifierLevel::parse(input.level.as_str())?;
331 if input.max_python_files == 0 || input.max_python_files > 1000 {
332 return Err(ToolError::invalid_input(
333 "max_python_files must be between 1 and 1000",
334 ));
335 }
336 if input.commands.len() > MAX_CUSTOM_GATES {
337 return Err(ToolError::invalid_input(format!(
338 "commands may contain at most {MAX_CUSTOM_GATES} custom gates"
339 )));
340 }
341
342 let gates = build_gate_plan(
343 context,
344 profile,
345 level,
346 input.max_python_files,
347 &input.commands,
348 )?;
349 if gates.is_empty() {
350 let verifier_verdict = VerifierVerdict::from_counts(0, 0, 0);
351 let output = RunVerifiersOutput {
352 success: false,
353 profile: profile.as_str().to_string(),
354 level: level.as_str().to_string(),
355 workspace: context.workspace.display().to_string(),
356 gate_count: 0,
357 passed: 0,
358 failed: 0,
359 skipped: 0,
360 verifier_verdict,
361 hunt_verdict: verifier_verdict.hunt_verdict().to_string(),
362 goal_status: verifier_verdict.goal_status().to_string(),
363 summary: "No verifier gates were detected. Provide custom commands or choose a profile that matches this workspace.".to_string(),
364 gates: Vec::new(),
365 };
366 return verifier_tool_result(&output);
367 }
368
369 if input.background {
370 return start_background_gates(context, profile, level, gates);
371 }
372
373 let mut handles = Vec::with_capacity(gates.len());
374 for gate in gates {
375 handles.push(tokio::task::spawn_blocking(move || run_gate(gate)));
376 }
377
378 let mut results = Vec::with_capacity(handles.len());
379 for handle in handles {
380 match handle.await {
381 Ok(result) => results.push(result),
382 Err(err) => results.push(GateResult {
383 name: "internal-join".to_string(),
384 ecosystem: "internal".to_string(),
385 status: GateStatus::Failed,
386 command: "tokio::task::spawn_blocking".to_string(),
387 cwd: context.workspace.display().to_string(),
388 exit_code: None,
389 duration_ms: 0,
390 stdout: String::new(),
391 stderr: format!("Verifier task join failed: {err}"),
392 stdout_truncated: false,
393 stderr_truncated: false,
394 skipped_reason: None,
395 }),
396 }
397 }
398 results.sort_by(|a, b| a.name.cmp(&b.name));
399
400 let passed = results
401 .iter()
402 .filter(|result| result.status == GateStatus::Passed)
403 .count();
404 let failed = results
405 .iter()
406 .filter(|result| result.status == GateStatus::Failed)
407 .count();
408 let skipped = results
409 .iter()
410 .filter(|result| result.status == GateStatus::Skipped)
411 .count();
412 let success = failed == 0 && skipped == 0;
413 let verifier_verdict = VerifierVerdict::from_counts(results.len(), failed, skipped);
414 let summary = if success {
415 format!("All {passed} verifier gates passed.")
416 } else {
417 format!("{passed} passed, {failed} failed, {skipped} skipped.")
418 };
419
420 let output = RunVerifiersOutput {
421 success,
422 profile: profile.as_str().to_string(),
423 level: level.as_str().to_string(),
424 workspace: context.workspace.display().to_string(),
425 gate_count: results.len(),
426 passed,
427 failed,
428 skipped,
429 verifier_verdict,
430 hunt_verdict: verifier_verdict.hunt_verdict().to_string(),
431 goal_status: verifier_verdict.goal_status().to_string(),
432 summary,
433 gates: results,
434 };
435
436 verifier_tool_result(&output)
437 }
438 }
439
440 /// Run quick auto verifier gates after a successful workflow completion (#4013).
441 pub(crate) async fn run_workflow_completion_gates(
442 context: &ToolContext,
443 ) -> Result<Value, ToolError> {
444 let gates = build_gate_plan(
445 context,
446 VerifierProfile::Auto,
447 VerifierLevel::Quick,
448 DEFAULT_MAX_PYTHON_FILES,
449 &[],
450 )?;
451 if gates.is_empty() {
452 return Ok(json!({
453 "success": false,
454 "profile": "auto",
455 "level": "quick",
456 "gate_count": 0,
457 "summary": "No verifier gates detected for this workspace.",
458 "gates": [],
459 }));
460 }
461
462 let workspace = context.workspace.display().to_string();
463 let mut handles = Vec::with_capacity(gates.len());
464 for gate in gates {
465 handles.push(tokio::task::spawn_blocking(move || run_gate(gate)));
466 }
467
468 let mut results = Vec::with_capacity(handles.len());
469 for handle in handles {
470 match handle.await {
471 Ok(result) => results.push(result),
472 Err(err) => results.push(GateResult {
473 name: "internal-join".to_string(),
474 ecosystem: "internal".to_string(),
475 status: GateStatus::Failed,
476 command: "tokio::task::spawn_blocking".to_string(),
477 cwd: workspace.clone(),
478 exit_code: None,
479 duration_ms: 0,
480 stdout: String::new(),
481 stderr: format!("Verifier task join failed: {err}"),
482 stdout_truncated: false,
483 stderr_truncated: false,
484 skipped_reason: None,
485 }),
486 }
487 }
488 results.sort_by(|a, b| a.name.cmp(&b.name));
489
490 let passed = results
491 .iter()
492 .filter(|result| result.status == GateStatus::Passed)
493 .count();
494 let failed = results
495 .iter()
496 .filter(|result| result.status == GateStatus::Failed)
497 .count();
498 let skipped = results
499 .iter()
500 .filter(|result| result.status == GateStatus::Skipped)
501 .count();
502 let success = failed == 0 && skipped == 0;
503 if !success {
504 return Err(ToolError::execution_failed(format!(
505 "{passed} passed, {failed} failed, {skipped} skipped"
506 )));
507 }
508 Ok(json!({
509 "success": true,
510 "profile": "auto",
511 "level": "quick",
512 "gate_count": results.len(),
513 "passed": passed,
514 "failed": failed,
515 "skipped": skipped,
516 "summary": format!("All {passed} verifier gates passed."),
517 "gates": results,
518 }))
519 }
520
521 fn verifier_tool_result(output: &RunVerifiersOutput) -> Result<ToolResult, ToolError> {
522 ToolResult::json(output)
523 .map_err(|err| ToolError::execution_failed(err.to_string()))
524 .map(|result| {
525 result.with_metadata(json!({
526 "verifier_verdict": output.verifier_verdict,
527 "hunt_verdict": output.hunt_verdict,
528 "goal_status": output.goal_status,
529 "task_updates": {
530 "hunt_verdict": output.hunt_verdict
531 }
532 }))
533 })
534 }
535
536 fn start_background_gates(
537 context: &ToolContext,
538 profile: VerifierProfile,
539 level: VerifierLevel,
540 gates: Vec<VerifierGate>,
541 ) -> Result<ToolResult, ToolError> {
542 let mut jobs = Vec::with_capacity(gates.len());
543 let mut started = 0usize;
544 let mut skipped = 0usize;
545 let mut failed_to_start = 0usize;
546
547 for gate in gates {
548 let cwd = gate.cwd.display().to_string();
549 let Some(program) = gate.program.as_deref() else {
550 skipped += 1;
551 jobs.push(BackgroundGateJob {
552 name: gate.name,
553 ecosystem: gate.ecosystem,
554 status: "skipped".to_string(),
555 command: String::new(),
556 cwd,
557 task_id: None,
558 skipped_reason: gate.skipped_reason,
559 error: None,
560 });
561 continue;
562 };
563
564 let command = render_gate_command(program, &gate.args)?;
565 let env: HashMap<String, String> = gate.env.into_iter().collect();
566 let spawn_result = {
567 let mut manager = context
568 .shell_manager
569 .lock()
570 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
571 manager.execute_with_options_env(
572 &command,
573 Some(&cwd),
574 BACKGROUND_GATE_TIMEOUT_MS,
575 true,
576 None,
577 false,
578 context.elevated_sandbox_policy.clone(),
579 env,
580 )
581 };
582
583 match spawn_result {
584 Ok(result) => {
585 started += 1;
586 jobs.push(BackgroundGateJob {
587 name: gate.name,
588 ecosystem: gate.ecosystem,
589 status: "running".to_string(),
590 command,
591 cwd,
592 task_id: result.task_id,
593 skipped_reason: None,
594 error: None,
595 });
596 }
597 Err(err) => {
598 failed_to_start += 1;
599 jobs.push(BackgroundGateJob {
600 name: gate.name,
601 ecosystem: gate.ecosystem,
602 status: "failed_to_start".to_string(),
603 command,
604 cwd,
605 task_id: None,
606 skipped_reason: None,
607 error: Some(err.to_string()),
608 });
609 }
610 }
611 }
612
613 jobs.sort_by(|a, b| a.name.cmp(&b.name));
614 let success = failed_to_start == 0 && started > 0;
615 let summary = if failed_to_start == 0 {
616 format!(
617 "Started {started} verifier gate(s) in the background; {skipped} skipped. Completion is tracked in task/status state. Continue inspecting or implementing while they run."
618 )
619 } else {
620 format!(
621 "Started {started} verifier gate(s), failed to start {failed_to_start}, and skipped {skipped}. Completion is tracked in task/status state. Continue inspecting or implementing while they run."
622 )
623 };
624 let task_ids = jobs
625 .iter()
626 .filter_map(|job| job.task_id.clone())
627 .collect::<Vec<_>>();
628 let output = RunVerifiersBackgroundOutput {
629 success,
630 profile: profile.as_str().to_string(),
631 level: level.as_str().to_string(),
632 workspace: context.workspace.display().to_string(),
633 background: true,
634 gate_count: jobs.len(),
635 started,
636 skipped,
637 failed_to_start,
638 summary,
639 jobs,
640 };
641
642 let mut result =
643 ToolResult::json(&output).map_err(|err| ToolError::execution_failed(err.to_string()))?;
644 result.success = success;
645 Ok(result.with_metadata(json!({
646 "backgrounded": true,
647 "detached_start": true,
648 "verifier_background": true,
649 "auto_resume_on_completion": false,
650 "completion_surface": "task_status",
651 "background_policy": "nonblocking",
652 "task_ids": task_ids,
653 "poll_with": ["exec_shell_wait", "task_shell_wait"]
654 })))
655 }
656
657 fn render_gate_command(program: &str, args: &[String]) -> Result<String, ToolError> {
658 try_join(std::iter::once(program).chain(args.iter().map(String::as_str)))
659 .map_err(|err| ToolError::execution_failed(format!("failed to render gate command: {err}")))
660 }
661
662 fn build_gate_plan(
663 context: &ToolContext,
664 profile: VerifierProfile,
665 level: VerifierLevel,
666 max_python_files: usize,
667 custom_commands: &[CustomVerifierInput],
668 ) -> Result<Vec<VerifierGate>, ToolError> {
669 let workspace = &context.workspace;
670 let mut gates = Vec::new();
671
672 if profile == VerifierProfile::Auto && workspace.join(".git").exists() {
673 gates.push(gate(
674 "git-whitespace",
675 "git",
676 workspace,
677 "git",
678 ["diff", "--check"],
679 ));
680 }
681
682 if profile_matches(profile, VerifierProfile::Rust) && workspace.join("Cargo.toml").exists() {
683 add_rust_gates(&mut gates, workspace, level);
684 }
685 if profile_matches(profile, VerifierProfile::Node) && workspace.join("package.json").exists() {
686 add_node_gates(&mut gates, workspace, level);
687 }
688 if profile_matches(profile, VerifierProfile::Python) && has_python_project(workspace) {
689 add_python_gates(&mut gates, workspace, level, max_python_files);
690 }
691 if profile_matches(profile, VerifierProfile::Go) && workspace.join("go.mod").exists() {
692 add_go_gates(&mut gates, workspace, level);
693 }
694
695 for custom in custom_commands {
696 gates.push(custom_gate(context, custom)?);
697 }
698
699 Ok(gates)
700 }
701
702 fn profile_matches(selected: VerifierProfile, candidate: VerifierProfile) -> bool {
703 selected == VerifierProfile::Auto || selected == candidate
704 }
705
706 fn add_rust_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
707 let locked = workspace.join("Cargo.lock").exists();
708 gates.push(gate(
709 "rust-fmt",
710 "rust",
711 workspace,
712 "cargo",
713 ["fmt", "--all", "--", "--check"],
714 ));
715
716 let metadata_args = if locked {
717 vec!["metadata", "--locked", "--format-version", "1", "--no-deps"]
718 } else {
719 vec!["metadata", "--format-version", "1", "--no-deps"]
720 };
721 gates.push(gate_vec(
722 "rust-metadata",
723 "rust",
724 workspace,
725 "cargo",
726 metadata_args,
727 ));
728
729 let mut check_args = vec!["check", "--workspace", "--all-targets"];
730 if locked {
731 check_args.push("--locked");
732 }
733 gates.push(gate_vec(
734 "rust-check",
735 "rust",
736 workspace,
737 "cargo",
738 check_args,
739 ));
740
741 if level == VerifierLevel::Full {
742 let mut clippy_args = vec!["clippy", "--workspace", "--all-targets", "--all-features"];
743 if locked {
744 clippy_args.push("--locked");
745 }
746 clippy_args.extend(["--", "-D", "warnings"]);
747 gates.push(gate_vec(
748 "rust-clippy",
749 "rust",
750 workspace,
751 "cargo",
752 clippy_args,
753 ));
754
755 let mut test_args = vec!["test", "--workspace", "--all-features"];
756 if locked {
757 test_args.push("--locked");
758 }
759 gates.push(gate_vec("rust-test", "rust", workspace, "cargo", test_args));
760 }
761 }
762
763 fn add_node_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
764 let scripts = package_json_scripts(workspace);
765 let Some(scripts) = scripts else {
766 gates.push(skipped_gate(
767 "node-package-json",
768 "node",
769 workspace,
770 "package.json is missing or could not be parsed",
771 ));
772 return;
773 };
774 let package_manager = detect_node_package_manager(workspace);
775 for script in ["format:check", "check", "typecheck", "lint"] {
776 if has_meaningful_script(&scripts, script) {
777 gates.push(node_script_gate(workspace, &package_manager, script));
778 }
779 }
780 if level == VerifierLevel::Full && has_meaningful_script(&scripts, "test") {
781 gates.push(node_script_gate(workspace, &package_manager, "test"));
782 }
783 }
784
785 fn add_python_gates(
786 gates: &mut Vec<VerifierGate>,
787 workspace: &Path,
788 level: VerifierLevel,
789 max_python_files: usize,
790 ) {
791 let python_files = collect_python_files(workspace, max_python_files);
792 match python_files {
793 PythonFiles::Files(files) if !files.is_empty() => {
794 gates.push(python_syntax_gate(workspace, &files));
795 }
796 PythonFiles::TooMany { limit, found } => gates.push(skipped_gate(
797 "python-syntax",
798 "python",
799 workspace,
800 format!(
801 "found more than {limit} Python files ({found}); raise max_python_files to verify them"
802 ),
803 )),
804 PythonFiles::Files(_) => {}
805 }
806
807 if level == VerifierLevel::Full && has_pytest_signal(workspace) {
808 gates.push(python_module_gate(
809 "python-pytest",
810 workspace,
811 ["-m", "pytest"],
812 ));
813 }
814 }
815
816 fn add_go_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
817 gates.push(gate("go-test", "go", workspace, "go", ["test", "./..."]));
818 if level == VerifierLevel::Full {
819 gates.push(gate("go-vet", "go", workspace, "go", ["vet", "./..."]));
820 }
821 }
822
823 fn gate<const N: usize>(
824 name: &str,
825 ecosystem: &str,
826 cwd: &Path,
827 program: &str,
828 args: [&str; N],
829 ) -> VerifierGate {
830 gate_vec(name, ecosystem, cwd, program, args)
831 }
832
833 fn gate_vec<I, S>(name: &str, ecosystem: &str, cwd: &Path, program: &str, args: I) -> VerifierGate
834 where
835 I: IntoIterator<Item = S>,
836 S: AsRef<str>,
837 {
838 VerifierGate {
839 name: name.to_string(),
840 ecosystem: ecosystem.to_string(),
841 cwd: cwd.to_path_buf(),
842 program: Some(program.to_string()),
843 args: args
844 .into_iter()
845 .map(|arg| arg.as_ref().to_string())
846 .collect(),
847 env: Vec::new(),
848 skipped_reason: None,
849 }
850 }
851
852 fn skipped_gate(
853 name: &str,
854 ecosystem: &str,
855 cwd: &Path,
856 reason: impl Into<String>,
857 ) -> VerifierGate {
858 VerifierGate {
859 name: name.to_string(),
860 ecosystem: ecosystem.to_string(),
861 cwd: cwd.to_path_buf(),
862 program: None,
863 args: Vec::new(),
864 env: Vec::new(),
865 skipped_reason: Some(reason.into()),
866 }
867 }
868
869 fn custom_gate(
870 context: &ToolContext,
871 custom: &CustomVerifierInput,
872 ) -> Result<VerifierGate, ToolError> {
873 if custom.name.trim().is_empty() {
874 return Err(ToolError::invalid_input(
875 "Custom verifier command is missing 'name'",
876 ));
877 }
878 if custom.program.trim().is_empty() {
879 return Err(ToolError::invalid_input(format!(
880 "Custom verifier '{}' is missing 'program'",
881 custom.name
882 )));
883 }
884 let cwd = match custom.cwd.as_deref() {
885 Some(raw) if !raw.trim().is_empty() => context.resolve_path(raw)?,
886 _ => context.workspace.clone(),
887 };
888 Ok(VerifierGate {
889 name: custom.name.clone(),
890 ecosystem: "custom".to_string(),
891 cwd,
892 program: Some(custom.program.clone()),
893 args: custom.args.clone(),
894 env: Vec::new(),
895 skipped_reason: None,
896 })
897 }
898
899 fn node_script_gate(
900 workspace: &Path,
901 package_manager: &NodePackageManager,
902 script: &str,
903 ) -> VerifierGate {
904 let (program, args) = package_manager.command_for_script(script);
905 gate_vec(&format!("node-{script}"), "node", workspace, program, args)
906 }
907
908 fn python_syntax_gate(workspace: &Path, files: &[PathBuf]) -> VerifierGate {
909 let Some((program, mut args)) = python_command_parts() else {
910 return skipped_gate(
911 "python-syntax",
912 "python",
913 workspace,
914 "Python interpreter is not installed or not in PATH",
915 );
916 };
917 args.push("-c".to_string());
918 args.push(PYTHON_SYNTAX_SCRIPT.to_string());
919 args.extend(files.iter().map(|path| path.display().to_string()));
920 let mut gate = gate_vec("python-syntax", "python", workspace, &program, args);
921 gate.env
922 .push(("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string()));
923 gate
924 }
925
926 fn python_module_gate<const N: usize>(
927 name: &str,
928 workspace: &Path,
929 module_args: [&str; N],
930 ) -> VerifierGate {
931 let Some((program, mut args)) = python_command_parts() else {
932 return skipped_gate(
933 name,
934 "python",
935 workspace,
936 "Python interpreter is not installed or not in PATH",
937 );
938 };
939 args.extend(module_args.into_iter().map(str::to_string));
940 gate_vec(name, "python", workspace, &program, args)
941 }
942
943 fn python_command_parts() -> Option<(String, Vec<String>)> {
944 let spec = crate::dependencies::Python::resolve()?;
945 Some(crate::dependencies::split_interpreter_spec(&spec))
946 }
947
948 const PYTHON_SYNTAX_SCRIPT: &str = r#"
949 import ast
950 import pathlib
951 import sys
952
953 failures = []
954 for raw in sys.argv[1:]:
955 path = pathlib.Path(raw)
956 try:
957 source = path.read_text(encoding="utf-8")
958 ast.parse(source, filename=raw)
959 except Exception as exc:
960 failures.append(f"{raw}: {exc.__class__.__name__}: {exc}")
961
962 if failures:
963 print("\n".join(failures), file=sys.stderr)
964 sys.exit(1)
965
966 print(f"parsed {len(sys.argv) - 1} Python file(s)")
967 "#;
968
969 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
970 enum NodePackageManager {
971 Npm,
972 Pnpm,
973 Yarn,
974 Bun,
975 }
976
977 impl NodePackageManager {
978 fn command_for_script(self, script: &str) -> (&'static str, Vec<String>) {
979 match self {
980 Self::Npm => ("npm", vec!["run".to_string(), script.to_string()]),
981 Self::Pnpm => ("pnpm", vec!["run".to_string(), script.to_string()]),
982 Self::Yarn => ("yarn", vec!["run".to_string(), script.to_string()]),
983 Self::Bun => ("bun", vec!["run".to_string(), script.to_string()]),
984 }
985 }
986 }
987
988 fn detect_node_package_manager(workspace: &Path) -> NodePackageManager {
989 if workspace.join("pnpm-lock.yaml").exists() {
990 NodePackageManager::Pnpm
991 } else if workspace.join("yarn.lock").exists() {
992 NodePackageManager::Yarn
993 } else if workspace.join("bun.lock").exists() || workspace.join("bun.lockb").exists() {
994 NodePackageManager::Bun
995 } else {
996 NodePackageManager::Npm
997 }
998 }
999
1000 fn package_json_scripts(workspace: &Path) -> Option<HashMap<String, String>> {
1001 let raw = fs::read_to_string(workspace.join("package.json")).ok()?;
1002 let parsed = serde_json::from_str::<serde_json::Value>(&raw).ok()?;
1003 let scripts = parsed.get("scripts")?.as_object()?;
1004 Some(
1005 scripts
1006 .iter()
1007 .filter_map(|(key, value)| {
1008 value
1009 .as_str()
1010 .map(|script| (key.clone(), script.to_string()))
1011 })
1012 .collect(),
1013 )
1014 }
1015
1016 fn has_meaningful_script(scripts: &HashMap<String, String>, name: &str) -> bool {
1017 let Some(script) = scripts.get(name).map(|value| value.trim()) else {
1018 return false;
1019 };
1020 !(script.is_empty()
1021 || name == "test"
1022 && script.contains("Error: no test specified")
1023 && script.contains("exit 1"))
1024 }
1025
1026 fn has_python_project(workspace: &Path) -> bool {
1027 workspace.join("pyproject.toml").exists()
1028 || workspace.join("setup.py").exists()
1029 || workspace.join("setup.cfg").exists()
1030 || workspace.join("requirements.txt").exists()
1031 || match collect_python_files(workspace, 1) {
1032 PythonFiles::Files(files) => !files.is_empty(),
1033 PythonFiles::TooMany { .. } => true,
1034 }
1035 }
1036
1037 fn has_pytest_signal(workspace: &Path) -> bool {
1038 if workspace.join("pytest.ini").exists()
1039 || workspace.join("tox.ini").exists()
1040 || workspace.join("tests").is_dir()
1041 {
1042 return true;
1043 }
1044 let pyproject = workspace.join("pyproject.toml");
1045 fs::read_to_string(pyproject)
1046 .map(|raw| raw.contains("pytest") || raw.contains("[tool.pytest"))
1047 .unwrap_or(false)
1048 }
1049
1050 #[derive(Debug, Clone, PartialEq, Eq)]
1051 enum PythonFiles {
1052 Files(Vec<PathBuf>),
1053 TooMany { limit: usize, found: usize },
1054 }
1055
1056 fn collect_python_files(workspace: &Path, limit: usize) -> PythonFiles {
1057 let mut files = BTreeSet::new();
1058 collect_python_files_inner(workspace, workspace, limit, &mut files);
1059 let found = files.len();
1060 if found > limit {
1061 PythonFiles::TooMany { limit, found }
1062 } else {
1063 PythonFiles::Files(files.into_iter().collect())
1064 }
1065 }
1066
1067 fn collect_python_files_inner(
1068 root: &Path,
1069 dir: &Path,
1070 limit: usize,
1071 files: &mut BTreeSet<PathBuf>,
1072 ) {
1073 if files.len() > limit {
1074 return;
1075 }
1076 let Ok(entries) = fs::read_dir(dir) else {
1077 return;
1078 };
1079 for entry in entries.flatten() {
1080 if files.len() > limit {
1081 return;
1082 }
1083 let path = entry.path();
1084 let name = entry.file_name();
1085 if path.is_dir() {
1086 if should_skip_dir_name(&name.to_string_lossy()) {
1087 continue;
1088 }
1089 collect_python_files_inner(root, &path, limit, files);
1090 } else if path.extension().and_then(|ext| ext.to_str()) == Some("py")
1091 && let Ok(relative) = path.strip_prefix(root)
1092 {
1093 files.insert(relative.to_path_buf());
1094 }
1095 }
1096 }
1097
1098 fn should_skip_dir_name(name: &str) -> bool {
1099 matches!(
1100 name,
1101 ".git"
1102 | ".hg"
1103 | ".svn"
1104 | ".venv"
1105 | "venv"
1106 | "env"
1107 | "__pycache__"
1108 | ".mypy_cache"
1109 | ".pytest_cache"
1110 | ".tox"
1111 | "node_modules"
1112 | "target"
1113 | "dist"
1114 | "build"
1115 )
1116 }
1117
1118 fn run_gate(gate: VerifierGate) -> GateResult {
1119 let command = render_command(gate.program.as_deref(), &gate.args);
1120 if let Some(reason) = gate.skipped_reason {
1121 return GateResult {
1122 name: gate.name,
1123 ecosystem: gate.ecosystem,
1124 status: GateStatus::Skipped,
1125 command,
1126 cwd: gate.cwd.display().to_string(),
1127 exit_code: None,
1128 duration_ms: 0,
1129 stdout: String::new(),
1130 stderr: String::new(),
1131 stdout_truncated: false,
1132 stderr_truncated: false,
1133 skipped_reason: Some(reason),
1134 };
1135 }
1136
1137 let Some(program) = gate.program else {
1138 return GateResult {
1139 name: gate.name,
1140 ecosystem: gate.ecosystem,
1141 status: GateStatus::Skipped,
1142 command,
1143 cwd: gate.cwd.display().to_string(),
1144 exit_code: None,
1145 duration_ms: 0,
1146 stdout: String::new(),
1147 stderr: String::new(),
1148 stdout_truncated: false,
1149 stderr_truncated: false,
1150 skipped_reason: Some("verifier has no executable program".to_string()),
1151 };
1152 };
1153
1154 let started = Instant::now();
1155 let mut cmd = Command::new(&program);
1156 cmd.args(&gate.args)
1157 .current_dir(&gate.cwd)
1158 .stdout(Stdio::piped())
1159 .stderr(Stdio::piped());
1160 for (key, value) in &gate.env {
1161 cmd.env(key, value);
1162 }
1163
1164 let output = match cmd.output() {
1165 Ok(output) => output,
1166 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1167 return GateResult {
1168 name: gate.name,
1169 ecosystem: gate.ecosystem,
1170 status: GateStatus::Skipped,
1171 command,
1172 cwd: gate.cwd.display().to_string(),
1173 exit_code: None,
1174 duration_ms: started.elapsed().as_millis() as u64,
1175 stdout: String::new(),
1176 stderr: String::new(),
1177 stdout_truncated: false,
1178 stderr_truncated: false,
1179 skipped_reason: Some(format!("{program} is not installed or not in PATH")),
1180 };
1181 }
1182 Err(err) => {
1183 return GateResult {
1184 name: gate.name,
1185 ecosystem: gate.ecosystem,
1186 status: GateStatus::Failed,
1187 command,
1188 cwd: gate.cwd.display().to_string(),
1189 exit_code: None,
1190 duration_ms: started.elapsed().as_millis() as u64,
1191 stdout: String::new(),
1192 stderr: format!("Failed to spawn verifier: {err}"),
1193 stdout_truncated: false,
1194 stderr_truncated: false,
1195 skipped_reason: None,
1196 };
1197 }
1198 };
1199
1200 let (stdout, stdout_truncated) = truncate_with_note(
1201 &String::from_utf8_lossy(&output.stdout),
1202 MAX_GATE_OUTPUT_CHARS,
1203 );
1204 let (stderr, stderr_truncated) = truncate_with_note(
1205 &String::from_utf8_lossy(&output.stderr),
1206 MAX_GATE_OUTPUT_CHARS,
1207 );
1208 GateResult {
1209 name: gate.name,
1210 ecosystem: gate.ecosystem,
1211 status: if output.status.success() {
1212 GateStatus::Passed
1213 } else {
1214 GateStatus::Failed
1215 },
1216 command,
1217 cwd: gate.cwd.display().to_string(),
1218 exit_code: output.status.code(),
1219 duration_ms: started.elapsed().as_millis() as u64,
1220 stdout,
1221 stderr,
1222 stdout_truncated,
1223 stderr_truncated,
1224 skipped_reason: None,
1225 }
1226 }
1227
1228 fn render_command(program: Option<&str>, args: &[String]) -> String {
1229 let mut parts = Vec::new();
1230 parts.push(program.unwrap_or("<unavailable>").to_string());
1231 parts.extend(args.iter().cloned());
1232 parts.join(" ")
1233 }
1234
1235 fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool) {
1236 if text.chars().count() <= max_chars {
1237 return (text.to_string(), false);
1238 }
1239 let end = char_boundary_index(text, max_chars);
1240 let truncated = &text[..end];
1241 let omitted_chars = text
1242 .chars()
1243 .count()
1244 .saturating_sub(truncated.chars().count());
1245 (
1246 format!(
1247 "{truncated}\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
1248 ),
1249 true,
1250 )
1251 }
1252
1253 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
1254 if max_chars == 0 {
1255 return 0;
1256 }
1257 for (count, (idx, _)) in text.char_indices().enumerate() {
1258 if count == max_chars {
1259 return idx;
1260 }
1261 }
1262 text.len()
1263 }
1264
1265 #[cfg(test)]
1266 mod tests {
1267 use super::*;
1268 use crate::tools::shell::ShellStatus;
1269 use std::time::Duration;
1270 use tempfile::tempdir;
1271
1272 const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;
1273
1274 fn wait_for_completed_shell(
1275 manager: &mut crate::tools::shell::ShellManager,
1276 task_id: &str,
1277 ) -> crate::tools::shell::ShellResult {
1278 let deadline = Instant::now() + Duration::from_millis(BACKGROUND_COMPLETION_WAIT_MS);
1279
1280 loop {
1281 let result = manager
1282 .get_output(task_id, true, 1_000)
1283 .expect("background output");
1284 if result.status != ShellStatus::Running || Instant::now() >= deadline {
1285 return result;
1286 }
1287 std::thread::sleep(Duration::from_millis(50));
1288 }
1289 }
1290
1291 #[test]
1292 fn run_verifiers_requires_user_approval() {
1293 let tool = RunVerifiersTool;
1294 assert_eq!(
1295 tool.approval_requirement(),
1296 ApprovalRequirement::Required,
1297 "run_verifiers executes project code and must require approval"
1298 );
1299 }
1300
1301 #[test]
1302 fn run_verifiers_background_advertises_detached_start() {
1303 let tool = RunVerifiersTool;
1304 let schema = tool.input_schema();
1305 let background_description = schema["properties"]["background"]["description"]
1306 .as_str()
1307 .expect("background description");
1308
1309 assert!(background_description.contains("Bash"));
1310 assert!(background_description.contains("task_shell_wait"));
1311 assert!(
1312 !background_description.contains("exec_shell"),
1313 "live descriptions must not teach the retired exec_shell name"
1314 );
1315 assert!(tool.starts_detached_for(&json!({"background": true})));
1316 assert!(!tool.starts_detached_for(&json!({"profile": "auto"})));
1317 }
1318
1319 #[test]
1320 fn auto_profile_detects_multiple_ecosystems_without_bash() {
1321 let tmp = tempdir().expect("tempdir");
1322 fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("cargo manifest");
1323 fs::write(
1324 tmp.path().join("package.json"),
1325 r#"{"scripts":{"lint":"eslint .","test":"echo ok"}}"#,
1326 )
1327 .expect("package json");
1328 fs::write(tmp.path().join("main.py"), "print('ok')\n").expect("python file");
1329 fs::write(tmp.path().join("go.mod"), "module example.com/app\n").expect("go mod");
1330
1331 let ctx = ToolContext::new(tmp.path());
1332 let gates = build_gate_plan(
1333 &ctx,
1334 VerifierProfile::Auto,
1335 VerifierLevel::Quick,
1336 DEFAULT_MAX_PYTHON_FILES,
1337 &[],
1338 )
1339 .expect("plan");
1340 let names: BTreeSet<&str> = gates.iter().map(|gate| gate.name.as_str()).collect();
1341
1342 assert!(names.contains("rust-fmt"));
1343 assert!(names.contains("node-lint"));
1344 assert!(names.contains("python-syntax"));
1345 assert!(names.contains("go-test"));
1346 assert!(
1347 gates
1348 .iter()
1349 .filter_map(|gate| gate.program.as_deref())
1350 .all(|program| program != "bash"),
1351 "built-in verifier gates must not require bash"
1352 );
1353 }
1354
1355 #[test]
1356 fn custom_commands_can_choose_bash_explicitly() {
1357 let tmp = tempdir().expect("tempdir");
1358 let ctx = ToolContext::new(tmp.path());
1359 let custom = CustomVerifierInput {
1360 name: "shell-check".to_string(),
1361 program: "bash".to_string(),
1362 args: vec!["-lc".to_string(), "echo ok".to_string()],
1363 cwd: None,
1364 };
1365
1366 let gate = custom_gate(&ctx, &custom).expect("custom gate");
1367
1368 assert_eq!(gate.program.as_deref(), Some("bash"));
1369 assert_eq!(gate.args, vec!["-lc", "echo ok"]);
1370 }
1371
1372 #[test]
1373 fn node_default_npm_init_test_script_is_not_a_verifier() {
1374 let mut scripts = HashMap::new();
1375 scripts.insert(
1376 "test".to_string(),
1377 "echo \"Error: no test specified\" && exit 1".to_string(),
1378 );
1379
1380 assert!(!has_meaningful_script(&scripts, "test"));
1381 }
1382
1383 #[tokio::test]
1384 async fn run_verifiers_executes_custom_direct_command() {
1385 if !crate::dependencies::RustC::available() {
1386 return;
1387 }
1388 let tmp = tempdir().expect("tempdir");
1389 let ctx = ToolContext::new(tmp.path());
1390 let tool = RunVerifiersTool;
1391 let result = tool
1392 .execute(
1393 json!({
1394 "profile": "auto",
1395 "commands": [
1396 {
1397 "name": "rustc-version",
1398 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1399 "args": ["--version"]
1400 }
1401 ]
1402 }),
1403 &ctx,
1404 )
1405 .await
1406 .expect("execute");
1407
1408 let parsed: RunVerifiersOutput =
1409 serde_json::from_str(&result.content).expect("verifier output json");
1410 assert!(parsed.success, "result: {}", result.content);
1411 assert_eq!(parsed.passed, 1);
1412 assert_eq!(parsed.failed, 0);
1413 assert_eq!(parsed.skipped, 0);
1414 assert!(
1415 parsed.gates[0].stdout.contains("rustc"),
1416 "stdout should include rustc version: {:?}",
1417 parsed.gates[0].stdout
1418 );
1419 }
1420
1421 #[tokio::test]
1422 async fn run_verifiers_emits_hunt_verdict_mapping() {
1423 let tmp = tempdir().expect("tempdir");
1424 let ctx = ToolContext::new(tmp.path());
1425 let tool = RunVerifiersTool;
1426
1427 let partial = tool
1428 .execute(json!({"profile": "auto"}), &ctx)
1429 .await
1430 .expect("execute partial verifier");
1431 assert_hunt_mapping(&partial.content, "partial", "wounded", "paused");
1432 assert_hunt_metadata(&partial, "partial", "wounded", "paused");
1433
1434 if !crate::dependencies::RustC::available() {
1435 return;
1436 }
1437
1438 let pass = tool
1439 .execute(
1440 json!({
1441 "profile": "auto",
1442 "commands": [
1443 {
1444 "name": "rustc-version",
1445 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1446 "args": ["--version"]
1447 }
1448 ]
1449 }),
1450 &ctx,
1451 )
1452 .await
1453 .expect("execute passing verifier");
1454 assert_hunt_mapping(&pass.content, "pass", "hunted", "complete");
1455 assert_hunt_metadata(&pass, "pass", "hunted", "complete");
1456
1457 let fail = tool
1458 .execute(
1459 json!({
1460 "profile": "auto",
1461 "commands": [
1462 {
1463 "name": "rustc-bad-flag",
1464 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1465 "args": ["--definitely-not-a-rustc-flag"]
1466 }
1467 ]
1468 }),
1469 &ctx,
1470 )
1471 .await
1472 .expect("execute failing verifier");
1473 assert_hunt_mapping(&fail.content, "fail", "escaped", "blocked");
1474 assert_hunt_metadata(&fail, "fail", "escaped", "blocked");
1475 }
1476
1477 fn assert_hunt_mapping(content: &str, verifier: &str, hunt: &str, goal: &str) {
1478 let parsed: Value = serde_json::from_str(content).expect("verifier output json");
1479 assert_eq!(parsed["verifier_verdict"], verifier, "{content}");
1480 assert_eq!(parsed["hunt_verdict"], hunt, "{content}");
1481 assert_eq!(parsed["goal_status"], goal, "{content}");
1482 }
1483
1484 fn assert_hunt_metadata(result: &ToolResult, verifier: &str, hunt: &str, goal: &str) {
1485 let metadata = result.metadata.as_ref().expect("hunt metadata");
1486 assert_eq!(metadata["verifier_verdict"], verifier, "{metadata}");
1487 assert_eq!(metadata["hunt_verdict"], hunt, "{metadata}");
1488 assert_eq!(metadata["goal_status"], goal, "{metadata}");
1489 assert_eq!(metadata["task_updates"]["hunt_verdict"], hunt, "{metadata}");
1490 }
1491
1492 #[tokio::test]
1493 #[allow(clippy::await_holding_lock)]
1494 async fn run_verifiers_background_starts_shell_jobs_and_returns_task_ids() {
1495 if !crate::dependencies::RustC::available() {
1496 return;
1497 }
1498 // The spawned `rustc` is usually the rustup shim, which resolves its
1499 // toolchain through $HOME. Hold the process-wide env mutex so tests
1500 // that temporarily swap HOME cannot break the child process.
1501 let _env_lock = crate::test_support::lock_test_env();
1502 let tmp = tempdir().expect("tempdir");
1503 let ctx = ToolContext::new(tmp.path());
1504 let tool = RunVerifiersTool;
1505 let result = tool
1506 .execute(
1507 json!({
1508 "profile": "auto",
1509 "background": true,
1510 "commands": [
1511 {
1512 "name": "rustc-version",
1513 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1514 "args": ["--version"]
1515 }
1516 ]
1517 }),
1518 &ctx,
1519 )
1520 .await
1521 .expect("execute");
1522
1523 let parsed: RunVerifiersBackgroundOutput =
1524 serde_json::from_str(&result.content).expect("background verifier output json");
1525 assert!(parsed.success, "result: {}", result.content);
1526 assert!(parsed.background);
1527 assert_eq!(parsed.started, 1);
1528 assert_eq!(parsed.failed_to_start, 0);
1529 assert!(parsed.summary.contains("Completion is tracked"));
1530 let task_id = parsed.jobs[0]
1531 .task_id
1532 .as_deref()
1533 .expect("background task id");
1534 let metadata = result.metadata.as_ref().expect("metadata");
1535 assert!(
1536 metadata
1537 .get("verifier_background")
1538 .and_then(Value::as_bool)
1539 .unwrap_or(false),
1540 "metadata should mark verifier background start"
1541 );
1542 assert_eq!(
1543 metadata
1544 .get("auto_notify_on_completion")
1545 .and_then(Value::as_bool),
1546 None
1547 );
1548 assert_eq!(
1549 metadata
1550 .get("auto_resume_on_completion")
1551 .and_then(Value::as_bool),
1552 Some(false)
1553 );
1554 assert_eq!(
1555 metadata.get("completion_surface").and_then(Value::as_str),
1556 Some("task_status")
1557 );
1558 assert_eq!(
1559 metadata.get("background_policy").and_then(Value::as_str),
1560 Some("nonblocking")
1561 );
1562
1563 let output = wait_for_completed_shell(
1564 &mut ctx.shell_manager.lock().expect("shell manager"),
1565 task_id,
1566 );
1567 assert_eq!(
1568 output.status,
1569 ShellStatus::Completed,
1570 "stdout: {:?} stderr: {:?}",
1571 output.stdout,
1572 output.stderr
1573 );
1574 assert!(
1575 output.stdout.contains("rustc"),
1576 "stdout should include rustc version: {:?}",
1577 output.stdout
1578 );
1579 }
1580 }
1581
1581 lines RUST