返回 DeepSeek-TUI-2026
tasks.rs
根目录 / crates / tui / src / tools / tasks.rs
1 //! Durable task, gate, and PR-attempt tools.
2
3 use std::path::{Path, PathBuf};
4 use std::process::Stdio;
5 use std::time::Instant;
6
7 use async_trait::async_trait;
8 use chrono::Utc;
9 use serde_json::{Value, json};
10 use tokio::process::Command;
11 use uuid::Uuid;
12
13 use crate::command_safety::{SafetyLevel, analyze_command};
14 use crate::task_manager::{
15 NewTaskRequest, TaskArtifactRef, TaskAttemptRecord, TaskGateRecord, TaskRecord,
16 };
17 use crate::tools::shell::{ExecShellTool, ShellWaitTool};
18 use crate::tools::spec::{
19 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
20 optional_bool, optional_str, optional_u64, required_str,
21 };
22
23 const MAX_SUMMARY_CHARS: usize = 900;
24 const DEFAULT_GATE_TIMEOUT_MS: u64 = 120_000;
25 const MAX_GATE_TIMEOUT_MS: u64 = 600_000;
26
27 pub struct TaskCreateTool;
28 pub struct TaskListTool;
29 pub struct TaskReadTool;
30 pub struct TaskCancelTool;
31 pub struct TaskGateRunTool;
32 pub struct TaskShellStartTool;
33 pub struct TaskShellWaitTool;
34 pub struct PrAttemptRecordTool;
35 pub struct PrAttemptListTool;
36 pub struct PrAttemptReadTool;
37 pub struct PrAttemptPreflightTool;
38
39 #[async_trait]
40 impl ToolSpec for TaskCreateTool {
41 fn name(&self) -> &'static str {
42 "task_create"
43 }
44
45 fn description(&self) -> &'static str {
46 "Create/enqueue a durable background task through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents."
47 }
48
49 fn input_schema(&self) -> Value {
50 json!({
51 "type": "object",
52 "properties": {
53 "prompt": { "type": "string", "description": "Work prompt for the durable task." },
54 "model": { "type": "string" },
55 "workspace": { "type": "string", "description": "Workspace path; defaults to current workspace." },
56 "mode": { "type": "string", "enum": ["agent", "plan", "yolo"] },
57 "allow_shell": { "type": "boolean" },
58 "trust_mode": { "type": "boolean" },
59 "auto_approve": { "type": "boolean" }
60 },
61 "required": ["prompt"],
62 "additionalProperties": false
63 })
64 }
65
66 fn capabilities(&self) -> Vec<ToolCapability> {
67 vec![ToolCapability::RequiresApproval]
68 }
69
70 fn approval_requirement(&self) -> ApprovalRequirement {
71 ApprovalRequirement::Required
72 }
73
74 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
75 let manager = context
76 .runtime
77 .task_manager
78 .as_ref()
79 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
80 let workspace = optional_str(&input, "workspace")
81 .map(PathBuf::from)
82 .unwrap_or_else(|| context.workspace.clone());
83 let req = NewTaskRequest {
84 prompt: required_str(&input, "prompt")?.to_string(),
85 model: optional_str(&input, "model").map(ToString::to_string),
86 workspace: Some(workspace),
87 mode: optional_str(&input, "mode").map(ToString::to_string),
88 allow_shell: input.get("allow_shell").and_then(Value::as_bool),
89 trust_mode: input.get("trust_mode").and_then(Value::as_bool),
90 auto_approve: input.get("auto_approve").and_then(Value::as_bool),
91 };
92 let task = manager
93 .add_task(req)
94 .await
95 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
96 task_result("task_create", &task)
97 }
98 }
99
100 #[async_trait]
101 impl ToolSpec for TaskListTool {
102 fn name(&self) -> &'static str {
103 "task_list"
104 }
105
106 fn description(&self) -> &'static str {
107 "List recent durable tasks with status, linked thread/turn ids, and concise summaries."
108 }
109
110 fn input_schema(&self) -> Value {
111 json!({
112 "type": "object",
113 "properties": {
114 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
115 },
116 "additionalProperties": false
117 })
118 }
119
120 fn capabilities(&self) -> Vec<ToolCapability> {
121 vec![ToolCapability::ReadOnly]
122 }
123
124 fn approval_requirement(&self) -> ApprovalRequirement {
125 ApprovalRequirement::Auto
126 }
127
128 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
129 let manager = context
130 .runtime
131 .task_manager
132 .as_ref()
133 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
134 let limit = optional_u64(&input, "limit", 20).clamp(1, 100) as usize;
135 let tasks = manager.list_tasks(Some(limit)).await;
136 ToolResult::json(&json!({
137 "summary": format!("{} durable task(s)", tasks.len()),
138 "tasks": tasks,
139 }))
140 .map_err(|e| ToolError::execution_failed(e.to_string()))
141 }
142 }
143
144 #[async_trait]
145 impl ToolSpec for TaskReadTool {
146 fn name(&self) -> &'static str {
147 "task_read"
148 }
149
150 fn description(&self) -> &'static str {
151 "Read durable task detail including timeline, checklist, gate evidence, artifacts, and PR attempts."
152 }
153
154 fn input_schema(&self) -> Value {
155 json!({
156 "type": "object",
157 "properties": {
158 "task_id": { "type": "string", "description": "Full task id or unambiguous prefix." }
159 },
160 "required": ["task_id"],
161 "additionalProperties": false
162 })
163 }
164
165 fn capabilities(&self) -> Vec<ToolCapability> {
166 vec![ToolCapability::ReadOnly]
167 }
168
169 fn approval_requirement(&self) -> ApprovalRequirement {
170 ApprovalRequirement::Auto
171 }
172
173 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
174 let manager = context
175 .runtime
176 .task_manager
177 .as_ref()
178 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
179 let task = manager
180 .get_task(required_str(&input, "task_id")?)
181 .await
182 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
183 task_result("task_read", &task)
184 }
185 }
186
187 #[async_trait]
188 impl ToolSpec for TaskCancelTool {
189 fn name(&self) -> &'static str {
190 "task_cancel"
191 }
192
193 fn description(&self) -> &'static str {
194 "Cancel a queued or running durable task through TaskManager. Requires approval because it changes work state."
195 }
196
197 fn input_schema(&self) -> Value {
198 json!({
199 "type": "object",
200 "properties": {
201 "task_id": { "type": "string", "description": "Full task id or unambiguous prefix." }
202 },
203 "required": ["task_id"],
204 "additionalProperties": false
205 })
206 }
207
208 fn capabilities(&self) -> Vec<ToolCapability> {
209 vec![ToolCapability::RequiresApproval]
210 }
211
212 fn approval_requirement(&self) -> ApprovalRequirement {
213 ApprovalRequirement::Required
214 }
215
216 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
217 let manager = context
218 .runtime
219 .task_manager
220 .as_ref()
221 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
222 let task = manager
223 .cancel_task(required_str(&input, "task_id")?)
224 .await
225 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
226 task_result("task_cancel", &task)
227 }
228 }
229
230 #[async_trait]
231 impl ToolSpec for TaskGateRunTool {
232 fn name(&self) -> &'static str {
233 "task_gate_run"
234 }
235
236 fn description(&self) -> &'static str {
237 "Run an approved verification gate command and return structured evidence. When inside a durable task, the gate result and log artifact are attached to that task."
238 }
239
240 fn input_schema(&self) -> Value {
241 json!({
242 "type": "object",
243 "properties": {
244 "gate": {
245 "type": "string",
246 "enum": ["fmt", "check", "clippy", "test", "custom"],
247 "description": "Gate category."
248 },
249 "command": { "type": "string", "description": "Command to run." },
250 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
251 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 }
252 },
253 "required": ["gate", "command"],
254 "additionalProperties": false
255 })
256 }
257
258 fn capabilities(&self) -> Vec<ToolCapability> {
259 vec![
260 ToolCapability::ExecutesCode,
261 ToolCapability::RequiresApproval,
262 ]
263 }
264
265 fn approval_requirement(&self) -> ApprovalRequirement {
266 ApprovalRequirement::Required
267 }
268
269 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
270 let gate = required_str(&input, "gate")?.to_string();
271 let command = required_str(&input, "command")?.to_string();
272 let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)
273 .clamp(1_000, MAX_GATE_TIMEOUT_MS);
274 let cwd = resolve_cwd(context, optional_str(&input, "cwd"))?;
275
276 let safety = analyze_command(&command);
277 if !context.auto_approve && matches!(safety.level, SafetyLevel::Dangerous) {
278 return Ok(ToolResult::error(format!(
279 "BLOCKED: gate command classified dangerous: {}",
280 safety.reasons.join("; ")
281 ))
282 .with_metadata(json!({
283 "safety_level": "dangerous",
284 "blocked": true,
285 "reasons": safety.reasons,
286 })));
287 }
288
289 let started = Instant::now();
290 let mut cmd = Command::new("/bin/sh");
291 cmd.arg("-lc")
292 .arg(&command)
293 .current_dir(&cwd)
294 .stdout(Stdio::piped())
295 .stderr(Stdio::piped());
296 let output =
297 tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), cmd.output()).await;
298
299 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
300 let (exit_code, stdout, stderr, timed_out, spawn_error) = match output {
301 Ok(Ok(out)) => (
302 out.status.code(),
303 String::from_utf8_lossy(&out.stdout).to_string(),
304 String::from_utf8_lossy(&out.stderr).to_string(),
305 false,
306 None,
307 ),
308 Ok(Err(err)) => (
309 None,
310 String::new(),
311 String::new(),
312 false,
313 Some(err.to_string()),
314 ),
315 Err(_) => (None, String::new(), String::new(), true, None),
316 };
317
318 let full_log = format!(
319 "$ {command}\n\n[stdout]\n{stdout}\n\n[stderr]\n{stderr}\n{}",
320 spawn_error
321 .as_ref()
322 .map(|e| format!("\n[spawn_error]\n{e}\n"))
323 .unwrap_or_default()
324 );
325 let summary_source = if !stderr.trim().is_empty() {
326 stderr.as_str()
327 } else if !stdout.trim().is_empty() {
328 stdout.as_str()
329 } else {
330 spawn_error.as_deref().unwrap_or("(no output)")
331 };
332 let summary = summarize(summary_source, MAX_SUMMARY_CHARS);
333 let status = if timed_out {
334 "timeout"
335 } else if spawn_error.is_some() {
336 "failed"
337 } else if exit_code == Some(0) {
338 "passed"
339 } else {
340 "failed"
341 };
342 let classification = classify_gate_failure(&gate, status, timed_out, &stderr, &stdout);
343 let log_path = write_runtime_artifact(context, "gate", &full_log)?;
344 let gate_record = TaskGateRecord {
345 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
346 gate: gate.clone(),
347 command: command.clone(),
348 cwd: cwd.clone(),
349 exit_code,
350 status: status.to_string(),
351 classification,
352 duration_ms,
353 summary: summary.clone(),
354 log_path: log_path.clone(),
355 recorded_at: Utc::now(),
356 };
357
358 let content = json!({
359 "gate": gate_record,
360 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
361 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
362 });
363 let mut metadata = json!({
364 "command": command,
365 "cwd": cwd,
366 "exit_code": exit_code,
367 "duration_ms": duration_ms,
368 "timed_out": timed_out,
369 "task_updates": {
370 "gate": gate_record,
371 "artifacts": artifact_updates("gate_log", log_path.clone(), &summary)
372 }
373 });
374 if let Some(path) = log_path {
375 metadata["artifact_path"] = json!(path);
376 }
377 Ok(ToolResult::json(&content)
378 .map_err(|e| ToolError::execution_failed(e.to_string()))?
379 .with_metadata(metadata))
380 }
381 }
382
383 #[async_trait]
384 impl ToolSpec for TaskShellStartTool {
385 fn name(&self) -> &'static str {
386 "task_shell_start"
387 }
388
389 fn description(&self) -> &'static str {
390 "Start a long-running shell command in the background and return a shell task_id immediately. Use task_shell_wait to poll and optionally record gate evidence on the active durable task."
391 }
392
393 fn input_schema(&self) -> Value {
394 json!({
395 "type": "object",
396 "properties": {
397 "command": { "type": "string" },
398 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
399 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
400 "stdin": { "type": "string" },
401 "tty": { "type": "boolean" }
402 },
403 "required": ["command"],
404 "additionalProperties": false
405 })
406 }
407
408 fn capabilities(&self) -> Vec<ToolCapability> {
409 vec![
410 ToolCapability::ExecutesCode,
411 ToolCapability::RequiresApproval,
412 ]
413 }
414
415 fn approval_requirement(&self) -> ApprovalRequirement {
416 ApprovalRequirement::Required
417 }
418
419 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
420 let mut shell_input = json!({
421 "command": required_str(&input, "command")?,
422 "background": true,
423 "timeout_ms": optional_u64(&input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)
424 .clamp(1_000, MAX_GATE_TIMEOUT_MS),
425 });
426 if let Some(cwd) = optional_str(&input, "cwd") {
427 let cwd = resolve_cwd(context, Some(cwd))?;
428 shell_input["cwd"] = json!(cwd);
429 }
430 if let Some(stdin) = optional_str(&input, "stdin") {
431 shell_input["stdin"] = json!(stdin);
432 }
433 if optional_bool(&input, "tty", false) {
434 shell_input["tty"] = json!(true);
435 }
436 let mut result = ExecShellTool.execute(shell_input, context).await?;
437 if let Some(metadata) = result.metadata.as_mut() {
438 metadata["background"] = json!(true);
439 metadata["task_shell"] = json!(true);
440 }
441 Ok(result)
442 }
443 }
444
445 #[async_trait]
446 impl ToolSpec for TaskShellWaitTool {
447 fn name(&self) -> &'static str {
448 "task_shell_wait"
449 }
450
451 fn description(&self) -> &'static str {
452 "Poll a background shell task without blocking the agent indefinitely. If `gate` is supplied and the shell task has completed, records structured gate evidence on the active durable task."
453 }
454
455 fn input_schema(&self) -> Value {
456 json!({
457 "type": "object",
458 "properties": {
459 "task_id": { "type": "string", "description": "Background shell task id returned by task_shell_start or exec_shell." },
460 "wait": { "type": "boolean", "default": false },
461 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
462 "gate": { "type": "string", "enum": ["fmt", "check", "clippy", "test", "custom"] },
463 "command": { "type": "string", "description": "Original command, used when recording gate evidence." }
464 },
465 "required": ["task_id"],
466 "additionalProperties": false
467 })
468 }
469
470 fn capabilities(&self) -> Vec<ToolCapability> {
471 vec![ToolCapability::ReadOnly]
472 }
473
474 fn approval_requirement(&self) -> ApprovalRequirement {
475 ApprovalRequirement::Auto
476 }
477
478 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
479 let result = ShellWaitTool::new("exec_shell_wait")
480 .execute(input.clone(), context)
481 .await?;
482 let Some(gate) = optional_str(&input, "gate") else {
483 return Ok(result);
484 };
485 let status = result
486 .metadata
487 .as_ref()
488 .and_then(|m| m.get("status"))
489 .and_then(Value::as_str)
490 .unwrap_or("Running");
491 if status == "Running" {
492 return Ok(result);
493 }
494 let exit_code = result
495 .metadata
496 .as_ref()
497 .and_then(|m| m.get("exit_code"))
498 .and_then(Value::as_i64)
499 .and_then(|v| i32::try_from(v).ok());
500 let duration_ms = result
501 .metadata
502 .as_ref()
503 .and_then(|m| m.get("duration_ms"))
504 .and_then(Value::as_u64)
505 .unwrap_or_default();
506 let command = optional_str(&input, "command").unwrap_or("(background shell)");
507 let log_path = write_runtime_artifact(context, "background_gate", &result.content)?;
508 let gate_status = if exit_code == Some(0) {
509 "passed"
510 } else if status == "TimedOut" {
511 "timeout"
512 } else {
513 "failed"
514 };
515 let gate_record = TaskGateRecord {
516 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
517 gate: gate.to_string(),
518 command: command.to_string(),
519 cwd: context.workspace.clone(),
520 exit_code,
521 status: gate_status.to_string(),
522 classification: classify_gate_failure(
523 gate,
524 gate_status,
525 status == "TimedOut",
526 &result.content,
527 "",
528 ),
529 duration_ms,
530 summary: summarize(&result.content, MAX_SUMMARY_CHARS),
531 log_path: log_path.clone(),
532 recorded_at: Utc::now(),
533 };
534 let mut metadata = result.metadata.clone().unwrap_or_else(|| json!({}));
535 metadata["background"] = json!(true);
536 metadata["task_updates"] = json!({
537 "gate": gate_record,
538 "artifacts": artifact_updates("background_gate_log", log_path, "Background shell gate output")
539 });
540 Ok(result.with_metadata(metadata))
541 }
542 }
543
544 #[async_trait]
545 impl ToolSpec for PrAttemptRecordTool {
546 fn name(&self) -> &'static str {
547 "pr_attempt_record"
548 }
549
550 fn description(&self) -> &'static str {
551 "Capture current git diff as a durable PR work attempt with patch artifact, changed files, and verification notes."
552 }
553
554 fn input_schema(&self) -> Value {
555 json!({
556 "type": "object",
557 "properties": {
558 "task_id": { "type": "string", "description": "Task to attach to; defaults to active task." },
559 "attempt_group_id": { "type": "string" },
560 "attempt_index": { "type": "integer", "minimum": 1 },
561 "attempt_count": { "type": "integer", "minimum": 1 },
562 "summary": { "type": "string" },
563 "verification": { "type": "array", "items": { "type": "string" } }
564 },
565 "required": ["summary"],
566 "additionalProperties": false
567 })
568 }
569
570 fn capabilities(&self) -> Vec<ToolCapability> {
571 vec![ToolCapability::ReadOnly]
572 }
573
574 fn approval_requirement(&self) -> ApprovalRequirement {
575 ApprovalRequirement::Auto
576 }
577
578 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
579 let task_id = task_id_from_input_or_context(&input, context)?;
580 let base_sha = git_output(&context.workspace, &["rev-parse", "HEAD"]).ok();
581 let head_sha = base_sha.clone();
582 let branch = git_output(&context.workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok();
583 let diff = git_output(&context.workspace, &["diff", "--binary", "--no-color"])?;
584 if diff.trim().is_empty() {
585 return Ok(ToolResult::error(
586 "No working-tree diff to record as an attempt.",
587 ));
588 }
589 let changed_files = git_output(&context.workspace, &["diff", "--name-only"])?
590 .lines()
591 .filter(|line| !line.trim().is_empty())
592 .map(ToString::to_string)
593 .collect::<Vec<_>>();
594 let patch_path = write_task_artifact_for(context, &task_id, "attempt_patch", &diff)?;
595 let attempt = TaskAttemptRecord {
596 id: format!("attempt_{}", &Uuid::new_v4().to_string()[..8]),
597 attempt_group_id: optional_str(&input, "attempt_group_id")
598 .map(ToString::to_string)
599 .unwrap_or_else(|| format!("attempt_group_{}", &Uuid::new_v4().to_string()[..8])),
600 attempt_index: optional_u64(&input, "attempt_index", 1).max(1) as u32,
601 attempt_count: optional_u64(&input, "attempt_count", 1).max(1) as u32,
602 base_ref: branch.clone(),
603 base_sha,
604 head_ref: branch,
605 head_sha,
606 summary: required_str(&input, "summary")?.to_string(),
607 changed_files,
608 patch_path: patch_path.clone(),
609 verification: input
610 .get("verification")
611 .and_then(Value::as_array)
612 .map(|items| {
613 items
614 .iter()
615 .filter_map(Value::as_str)
616 .map(ToString::to_string)
617 .collect()
618 })
619 .unwrap_or_default(),
620 selected: false,
621 recorded_at: Utc::now(),
622 };
623 let metadata = json!({
624 "task_id": task_id,
625 "task_updates": {
626 "attempt": attempt,
627 "artifacts": artifact_updates("attempt_patch", patch_path.clone(), "Captured git diff for PR attempt")
628 }
629 });
630 if context.runtime.active_task_id.as_deref() != Some(task_id.as_str())
631 && let Some(manager) = context.runtime.task_manager.as_ref()
632 {
633 manager
634 .record_tool_metadata(&task_id, &metadata)
635 .await
636 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
637 }
638 Ok(ToolResult::json(&metadata)
639 .map_err(|e| ToolError::execution_failed(e.to_string()))?
640 .with_metadata(metadata))
641 }
642 }
643
644 #[async_trait]
645 impl ToolSpec for PrAttemptListTool {
646 fn name(&self) -> &'static str {
647 "pr_attempt_list"
648 }
649
650 fn description(&self) -> &'static str {
651 "List PR attempts recorded on a durable task."
652 }
653
654 fn input_schema(&self) -> Value {
655 task_id_schema()
656 }
657
658 fn capabilities(&self) -> Vec<ToolCapability> {
659 vec![ToolCapability::ReadOnly]
660 }
661
662 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
663 let task = read_task_for_input(&input, context).await?;
664 ToolResult::json(&json!({ "task_id": task.id, "attempts": task.attempts }))
665 .map_err(|e| ToolError::execution_failed(e.to_string()))
666 }
667 }
668
669 #[async_trait]
670 impl ToolSpec for PrAttemptReadTool {
671 fn name(&self) -> &'static str {
672 "pr_attempt_read"
673 }
674
675 fn description(&self) -> &'static str {
676 "Read one recorded PR attempt and its patch artifact reference."
677 }
678
679 fn input_schema(&self) -> Value {
680 json!({
681 "type": "object",
682 "properties": {
683 "task_id": { "type": "string", "description": "Task id; defaults to active task." },
684 "attempt_id": { "type": "string" }
685 },
686 "required": ["attempt_id"],
687 "additionalProperties": false
688 })
689 }
690
691 fn capabilities(&self) -> Vec<ToolCapability> {
692 vec![ToolCapability::ReadOnly]
693 }
694
695 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
696 let task = read_task_for_input(&input, context).await?;
697 let attempt_id = required_str(&input, "attempt_id")?;
698 let attempt = task
699 .attempts
700 .iter()
701 .find(|attempt| attempt.id == attempt_id)
702 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
703 ToolResult::json(attempt).map_err(|e| ToolError::execution_failed(e.to_string()))
704 }
705 }
706
707 #[async_trait]
708 impl ToolSpec for PrAttemptPreflightTool {
709 fn name(&self) -> &'static str {
710 "pr_attempt_preflight"
711 }
712
713 fn description(&self) -> &'static str {
714 "Run `git apply --check` for a recorded attempt patch. This is a no-mutation preflight; actual apply remains explicit and approval-gated elsewhere."
715 }
716
717 fn input_schema(&self) -> Value {
718 json!({
719 "type": "object",
720 "properties": {
721 "task_id": { "type": "string", "description": "Task id; defaults to active task." },
722 "attempt_id": { "type": "string" }
723 },
724 "required": ["attempt_id"],
725 "additionalProperties": false
726 })
727 }
728
729 fn capabilities(&self) -> Vec<ToolCapability> {
730 vec![ToolCapability::ReadOnly]
731 }
732
733 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
734 let manager = context
735 .runtime
736 .task_manager
737 .as_ref()
738 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
739 let task = read_task_for_input(&input, context).await?;
740 let attempt_id = required_str(&input, "attempt_id")?;
741 let attempt = task
742 .attempts
743 .iter()
744 .find(|attempt| attempt.id == attempt_id)
745 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
746 let patch_ref = attempt
747 .patch_path
748 .as_ref()
749 .ok_or_else(|| ToolError::invalid_input("Attempt has no patch artifact"))?;
750 let patch_path = manager.artifact_absolute_path(patch_ref);
751 let out = Command::new("git")
752 .args(["apply", "--check"])
753 .arg(&patch_path)
754 .current_dir(&context.workspace)
755 .output()
756 .await
757 .map_err(|e| ToolError::execution_failed(format!("git apply --check failed: {e}")))?;
758 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
759 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
760 Ok(ToolResult::json(&json!({
761 "attempt_id": attempt_id,
762 "patch_path": patch_ref,
763 "would_apply": out.status.success(),
764 "exit_code": out.status.code(),
765 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
766 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
767 "mutated_worktree": false
768 }))
769 .map_err(|e| ToolError::execution_failed(e.to_string()))?)
770 }
771 }
772
773 fn task_result(label: &str, task: &TaskRecord) -> Result<ToolResult, ToolError> {
774 ToolResult::json(&json!({
775 "summary": format!("{label}: {} ({:?})", task.id, task.status),
776 "task": task,
777 }))
778 .map_err(|e| ToolError::execution_failed(e.to_string()))
779 }
780
781 fn resolve_cwd(context: &ToolContext, raw: Option<&str>) -> Result<PathBuf, ToolError> {
782 match raw {
783 Some(path) => {
784 let resolved = context.resolve_path(path)?;
785 if resolved.is_dir() {
786 Ok(resolved)
787 } else {
788 Err(ToolError::invalid_input(format!(
789 "cwd must be a directory: {path}"
790 )))
791 }
792 }
793 None => Ok(context.workspace.clone()),
794 }
795 }
796
797 fn write_runtime_artifact(
798 context: &ToolContext,
799 label: &str,
800 content: &str,
801 ) -> Result<Option<PathBuf>, ToolError> {
802 let Some(task_id) = context.runtime.active_task_id.as_deref() else {
803 return Ok(None);
804 };
805 let manager = context.runtime.task_manager.as_ref();
806 if let Some(manager) = manager {
807 return manager
808 .write_task_artifact(task_id, label, content)
809 .map(Some)
810 .map_err(|e| ToolError::execution_failed(e.to_string()));
811 }
812 let Some(data_dir) = context.runtime.task_data_dir.as_ref() else {
813 return Ok(None);
814 };
815 let artifact_dir = data_dir.join("artifacts").join(task_id);
816 std::fs::create_dir_all(&artifact_dir)
817 .map_err(|e| ToolError::execution_failed(format!("create artifact dir: {e}")))?;
818 let filename = format!(
819 "{}_{}.txt",
820 Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
821 sanitize_filename(label)
822 );
823 let absolute = artifact_dir.join(filename);
824 std::fs::write(&absolute, content)
825 .map_err(|e| ToolError::execution_failed(format!("write artifact: {e}")))?;
826 Ok(Some(
827 absolute
828 .strip_prefix(data_dir)
829 .map(PathBuf::from)
830 .unwrap_or(absolute),
831 ))
832 }
833
834 fn write_task_artifact_for(
835 context: &ToolContext,
836 task_id: &str,
837 label: &str,
838 content: &str,
839 ) -> Result<Option<PathBuf>, ToolError> {
840 if let Some(manager) = context.runtime.task_manager.as_ref() {
841 return manager
842 .write_task_artifact(task_id, label, content)
843 .map(Some)
844 .map_err(|e| ToolError::execution_failed(e.to_string()));
845 }
846 if context.runtime.active_task_id.as_deref() != Some(task_id) {
847 return Ok(None);
848 }
849 write_runtime_artifact(context, label, content)
850 }
851
852 fn artifact_updates(label: &str, path: Option<PathBuf>, summary: &str) -> Value {
853 match path {
854 Some(path) => json!([TaskArtifactRef {
855 label: label.to_string(),
856 path,
857 summary: summarize(summary, 240),
858 created_at: Utc::now(),
859 }]),
860 None => json!([]),
861 }
862 }
863
864 async fn read_task_for_input(
865 input: &Value,
866 context: &ToolContext,
867 ) -> Result<TaskRecord, ToolError> {
868 let manager = context
869 .runtime
870 .task_manager
871 .as_ref()
872 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
873 let task_id = task_id_from_input_or_context(input, context)?;
874 manager
875 .get_task(&task_id)
876 .await
877 .map_err(|e| ToolError::execution_failed(e.to_string()))
878 }
879
880 fn task_id_from_input_or_context(
881 input: &Value,
882 context: &ToolContext,
883 ) -> Result<String, ToolError> {
884 optional_str(input, "task_id")
885 .map(ToString::to_string)
886 .or_else(|| context.runtime.active_task_id.clone())
887 .ok_or_else(|| {
888 ToolError::invalid_input("task_id is required when no durable task is active")
889 })
890 }
891
892 fn task_id_schema() -> Value {
893 json!({
894 "type": "object",
895 "properties": {
896 "task_id": { "type": "string", "description": "Task id; defaults to active task." }
897 },
898 "additionalProperties": false
899 })
900 }
901
902 fn git_output(workspace: &Path, args: &[&str]) -> Result<String, ToolError> {
903 let out = std::process::Command::new("git")
904 .args(args)
905 .current_dir(workspace)
906 .output()
907 .map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
908 if !out.status.success() {
909 return Err(ToolError::execution_failed(format!(
910 "git {} failed: {}",
911 args.join(" "),
912 String::from_utf8_lossy(&out.stderr).trim()
913 )));
914 }
915 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
916 }
917
918 fn classify_gate_failure(
919 gate: &str,
920 status: &str,
921 timed_out: bool,
922 stderr: &str,
923 stdout: &str,
924 ) -> String {
925 if timed_out {
926 return "timeout".to_string();
927 }
928 if status == "passed" {
929 return "passed".to_string();
930 }
931 let haystack = format!("{stderr}\n{stdout}").to_ascii_lowercase();
932 if haystack.contains("address already in use") || haystack.contains("port") {
933 "environment_port_binding".to_string()
934 } else if gate == "clippy" || haystack.contains("warning:") {
935 "lint_failure".to_string()
936 } else if gate == "test" || haystack.contains("test result: failed") {
937 "test_failure".to_string()
938 } else if haystack.contains("error: could not compile")
939 || haystack.contains("compilation failed")
940 {
941 "compile_error".to_string()
942 } else {
943 "environment_or_tooling_failure".to_string()
944 }
945 }
946
947 fn summarize(text: &str, limit: usize) -> String {
948 let mut out = String::new();
949 for (idx, ch) in text.chars().enumerate() {
950 if idx >= limit.saturating_sub(3) {
951 out.push_str("...");
952 return out;
953 }
954 if ch.is_control() && ch != '\n' && ch != '\t' {
955 continue;
956 }
957 out.push(ch);
958 }
959 if out.trim().is_empty() {
960 "(no output)".to_string()
961 } else {
962 out
963 }
964 }
965
966 fn sanitize_filename(input: &str) -> String {
967 let mut out = String::new();
968 for ch in input.chars() {
969 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
970 out.push(ch);
971 } else {
972 out.push('_');
973 }
974 }
975 if out.is_empty() {
976 "artifact".to_string()
977 } else {
978 out
979 }
980 }
981
982 #[cfg(test)]
983 mod tests {
984 use super::*;
985 use crate::tools::spec::ToolSpec;
986
987 #[test]
988 fn durable_task_schema_requires_prompt() {
989 let schema = TaskCreateTool.input_schema();
990 assert_eq!(schema["required"][0], "prompt");
991 assert!(schema["properties"]["prompt"].is_object());
992 }
993
994 #[test]
995 fn gate_classifier_detects_timeout() {
996 assert_eq!(
997 classify_gate_failure("test", "timeout", true, "", ""),
998 "timeout"
999 );
1000 }
1001
1002 #[test]
1003 fn background_shell_schema_is_explicit() {
1004 let schema = TaskShellStartTool.input_schema();
1005 assert_eq!(schema["required"][0], "command");
1006 assert_eq!(schema["properties"]["timeout_ms"]["maximum"], 600000);
1007
1008 let wait_schema = TaskShellWaitTool.input_schema();
1009 assert_eq!(wait_schema["required"][0], "task_id");
1010 assert!(wait_schema["properties"]["gate"].is_object());
1011 }
1012 }
1013
1013 lines RUST