返回 CodeWhale
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::dependencies::ExternalTool;
14 use crate::task_manager::{
15 NewTaskRequest, TaskArtifactRef, TaskAttemptRecord, TaskCancelDisposition, TaskGateRecord,
16 TaskRecord,
17 };
18 use crate::tools::shell::BashTool;
19 use crate::tools::spec::{
20 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
21 optional_bool, optional_bool_opt, optional_str, optional_u64, required_str,
22 };
23 use crate::work_graph::{
24 CancelOutcome, OperationIntent, OperationObservation, OperationOwnerSnapshot, OwnerState,
25 task_owner_snapshot,
26 };
27 use codewhale_execpolicy::command_safety::{SafetyLevel, analyze_command};
28
29 const MAX_SUMMARY_CHARS: usize = 900;
30 const DEFAULT_GATE_TIMEOUT_MS: u64 = 120_000;
31 const MAX_GATE_TIMEOUT_MS: u64 = 600_000;
32
33 fn build_gate_command_parts(command: &str) -> (String, Vec<String>) {
34 (
35 "/bin/sh".to_string(),
36 vec!["-lc".to_string(), command.to_string()],
37 )
38 }
39
40 fn build_gate_command(command: &str, cwd: &Path) -> Command {
41 let (program, args) = build_gate_command_parts(command);
42 let mut cmd = Command::new(program);
43 cmd.args(args)
44 .current_dir(cwd)
45 .stdout(Stdio::piped())
46 .stderr(Stdio::piped());
47 cmd
48 }
49
50 fn task_shell_wait_input(mut input: Value) -> Value {
51 if input.get("wait").is_none_or(Value::is_null)
52 && input.get("block").is_none_or(Value::is_null)
53 && let Some(object) = input.as_object_mut()
54 {
55 object.insert("wait".to_string(), Value::Bool(false));
56 }
57 input
58 }
59
60 /// Unified durable-task tool (piagent phase B).
61 ///
62 /// The model sees one tool, `tasks`, with an `action` parameter routing to
63 /// the per-action logic below. The per-action `task_*` / `pr_attempt_*`
64 /// execution aliases were removed in v0.9.3.
65 ///
66 /// `TaskShellStartTool` / `TaskShellWaitTool` stay separate: the registry
67 /// gates them behind `allow_shell` (see `with_runtime_task_shell_tools`),
68 /// which differs from every other action in this family.
69 pub struct TasksTool {
70 name: &'static str,
71 forced_action: Option<&'static str>,
72 read_only: bool,
73 }
74
75 pub struct TaskShellStartTool;
76 pub struct TaskShellWaitTool;
77
78 /// Actions the Plan-mode read-only surface exposes.
79 const READ_ACTIONS: &[&str] = &["list", "read", "pr_attempt_list", "pr_attempt_read"];
80 const ALL_ACTIONS: &[&str] = &[
81 "create",
82 "list",
83 "read",
84 "cancel",
85 "gate_run",
86 "pr_attempt_record",
87 "pr_attempt_list",
88 "pr_attempt_read",
89 "pr_attempt_preflight",
90 ];
91
92 impl TasksTool {
93 pub const fn new(name: &'static str) -> Self {
94 Self {
95 name,
96 forced_action: None,
97 read_only: false,
98 }
99 }
100
101 /// Plan-mode variant: only the read-only actions are advertised and routed.
102 pub const fn read_only(name: &'static str) -> Self {
103 Self {
104 name,
105 forced_action: None,
106 read_only: true,
107 }
108 }
109
110 #[cfg(test)]
111 pub const fn alias(name: &'static str, action: &'static str) -> Self {
112 Self {
113 name,
114 forced_action: Some(action),
115 read_only: false,
116 }
117 }
118
119 fn allowed_actions(&self) -> &'static [&'static str] {
120 if self.read_only {
121 READ_ACTIONS
122 } else {
123 ALL_ACTIONS
124 }
125 }
126
127 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
128 let action = match self.forced_action {
129 Some(action) => action,
130 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
131 ToolError::invalid_input(format!(
132 "tasks: missing `action` (one of: {})",
133 self.allowed_actions().join(", ")
134 ))
135 })?,
136 };
137 if self.allowed_actions().contains(&action) {
138 Ok(action)
139 } else {
140 Err(ToolError::invalid_input(format!(
141 "tasks: invalid action `{action}` (one of: {})",
142 self.allowed_actions().join(", ")
143 )))
144 }
145 }
146
147 fn action_is_read(action: &str) -> bool {
148 READ_ACTIONS.contains(&action)
149 }
150
151 /// Whether this action executes code (drives static capabilities and the
152 /// Plan-mode "no ExecutesCode tools" invariant).
153 fn action_executes_code(action: &str) -> bool {
154 action == "gate_run"
155 }
156
157 fn action_requires_approval(action: &str) -> bool {
158 !Self::action_is_read(action)
159 }
160 }
161
162 #[async_trait]
163 impl ToolSpec for TasksTool {
164 fn name(&self) -> &'static str {
165 self.name
166 }
167
168 fn model_visible(&self) -> bool {
169 self.forced_action.is_none()
170 }
171
172 fn description(&self) -> &'static str {
173 match self.forced_action {
174 Some("create") => {
175 "Create/enqueue a durable background task through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents."
176 }
177 Some("list") => {
178 "List recent durable tasks with status, linked thread/turn ids, and concise summaries."
179 }
180 Some("read") => {
181 "Read durable task detail including timeline, checklist, gate evidence, artifacts, and PR attempts."
182 }
183 Some("cancel") => {
184 "Cancel a queued or running durable task through TaskManager. Requires approval because it changes work state."
185 }
186 Some("gate_run") => {
187 "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."
188 }
189 Some("pr_attempt_record") => {
190 "Capture current git diff as a durable PR work attempt with patch artifact, changed files, and verification notes."
191 }
192 Some("pr_attempt_list") => "List PR attempts recorded on a durable task.",
193 Some("pr_attempt_read") => {
194 "Read one recorded PR attempt and its patch artifact reference."
195 }
196 Some("pr_attempt_preflight") => {
197 "Run `git apply --check` for a recorded attempt patch. This is a no-mutation preflight; actual apply remains explicit and approval-gated elsewhere."
198 }
199 _ if self.read_only => {
200 "Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"."
201 }
202 _ => {
203 "Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\", \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\". Use task_shell_start for long-running shell work."
204 }
205 }
206 }
207
208 fn input_schema(&self) -> Value {
209 if let Some(action) = self.forced_action {
210 return legacy_action_schema(action);
211 }
212 let actions: Vec<&str> = self.allowed_actions().to_vec();
213 let mut properties = serde_json::Map::new();
214 properties.insert(
215 "action".to_string(),
216 json!({
217 "type": "string",
218 "enum": actions,
219 "description": "Action to perform."
220 }),
221 );
222 if !self.read_only {
223 properties.insert(
224 "prompt".to_string(),
225 json!({ "type": "string", "description": "Work prompt for the durable task (action=create)." }),
226 );
227 properties.insert(
228 "name".to_string(),
229 json!({ "type": "string", "description": "Short run name shown in queues; omit to derive from the prompt. (action=create)" }),
230 );
231 properties.insert(
232 "model_provider".to_string(),
233 json!({ "type": "string", "description": "Provider kind for the pinned model. Omit to inherit the configured provider." }),
234 );
235 properties.insert(
236 "model_provider_id".to_string(),
237 json!({ "type": "string", "description": "Exact configured provider id, including named custom routes. Keeps the model on that route." }),
238 );
239 properties.insert(
240 "model".to_string(),
241 json!({ "type": "string", "description": "(action=create)" }),
242 );
243 properties.insert(
244 "workspace".to_string(),
245 json!({ "type": "string", "description": "Workspace path; defaults to current workspace. (action=create)" }),
246 );
247 properties.insert(
248 "mode".to_string(),
249 json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "(action=create)" }),
250 );
251 properties.insert(
252 "allow_shell".to_string(),
253 json!({ "type": "boolean", "description": "(action=create)" }),
254 );
255 properties.insert(
256 "trust_mode".to_string(),
257 json!({ "type": "boolean", "description": "(action=create)" }),
258 );
259 properties.insert(
260 "auto_approve".to_string(),
261 json!({ "type": "boolean", "description": "(action=create)" }),
262 );
263 properties.insert(
264 "gate".to_string(),
265 json!({
266 "type": "string",
267 "enum": ["fmt", "check", "clippy", "test", "custom"],
268 "description": "Gate category. (action=gate_run)"
269 }),
270 );
271 properties.insert(
272 "command".to_string(),
273 json!({ "type": "string", "description": "Command to run. (action=gate_run)" }),
274 );
275 properties.insert(
276 "cwd".to_string(),
277 json!({ "type": "string", "description": "Optional working directory within the workspace. (action=gate_run)" }),
278 );
279 properties.insert(
280 "timeout_ms".to_string(),
281 json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "(action=gate_run)" }),
282 );
283 properties.insert(
284 "attempt_group_id".to_string(),
285 json!({ "type": "string", "description": "(action=pr_attempt_record)" }),
286 );
287 properties.insert(
288 "attempt_index".to_string(),
289 json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }),
290 );
291 properties.insert(
292 "attempt_count".to_string(),
293 json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }),
294 );
295 properties.insert(
296 "summary".to_string(),
297 json!({ "type": "string", "description": "Attempt summary (action=pr_attempt_record)." }),
298 );
299 properties.insert(
300 "verification".to_string(),
301 json!({ "type": "array", "items": { "type": "string" }, "description": "(action=pr_attempt_record)" }),
302 );
303 }
304 properties.insert(
305 "attempt_id".to_string(),
306 json!({ "type": "string", "description": "(action=pr_attempt_read/preflight)" }),
307 );
308 properties.insert(
309 "task_id".to_string(),
310 json!({ "type": "string", "description": "Full task id or unambiguous prefix (action=read/cancel); task id, defaults to active task (action=pr_attempt_*)." }),
311 );
312 properties.insert(
313 "limit".to_string(),
314 json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "(action=list)" }),
315 );
316 json!({
317 "type": "object",
318 "properties": properties,
319 "additionalProperties": false
320 })
321 }
322
323 fn capabilities(&self) -> Vec<ToolCapability> {
324 match self.forced_action {
325 Some(action) if Self::action_executes_code(action) => {
326 vec![
327 ToolCapability::ExecutesCode,
328 ToolCapability::RequiresApproval,
329 ]
330 }
331 Some(action) if Self::action_is_read(action) => vec![ToolCapability::ReadOnly],
332 Some(_) => vec![ToolCapability::RequiresApproval],
333 None if self.read_only => vec![ToolCapability::ReadOnly],
334 None => vec![
335 ToolCapability::ExecutesCode,
336 ToolCapability::RequiresApproval,
337 ],
338 }
339 }
340
341 fn approval_requirement(&self) -> ApprovalRequirement {
342 match self.forced_action {
343 Some(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
344 Some(_) => ApprovalRequirement::Auto,
345 None if self.read_only => ApprovalRequirement::Auto,
346 None => ApprovalRequirement::Required,
347 }
348 }
349
350 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
351 match self.resolve_action(input) {
352 Ok(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
353 Ok(_) => ApprovalRequirement::Auto,
354 Err(_) => self.approval_requirement(),
355 }
356 }
357
358 fn is_read_only_for(&self, input: &Value) -> bool {
359 match self.resolve_action(input) {
360 Ok(action) => Self::action_is_read(action),
361 Err(_) => self.is_read_only(),
362 }
363 }
364
365 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
366 crate::core::engine::tool_catalog::enforce_tool_denial(
367 context,
368 self.name(),
369 &json!({"action": self.resolve_action(&input)?}),
370 )?;
371 match self.resolve_action(&input)? {
372 "create" => self.execute_create(&input, context).await,
373 "list" => self.execute_list(&input, context).await,
374 "read" => self.execute_read(&input, context).await,
375 "cancel" => self.execute_cancel(&input, context).await,
376 "gate_run" => self.execute_gate_run(&input, context).await,
377 "pr_attempt_record" => self.execute_pr_attempt_record(&input, context).await,
378 "pr_attempt_list" => self.execute_pr_attempt_list(&input, context).await,
379 "pr_attempt_read" => self.execute_pr_attempt_read(&input, context).await,
380 "pr_attempt_preflight" => self.execute_pr_attempt_preflight(&input, context).await,
381 action => Err(ToolError::invalid_input(format!(
382 "tasks: invalid action `{action}`"
383 ))),
384 }
385 }
386 }
387
388 /// The exact schema the legacy per-action tool exposed, kept so hidden alias
389 /// registrations report an identical contract to the pre-unification tools.
390 fn legacy_action_schema(action: &str) -> Value {
391 match action {
392 "create" => json!({
393 "type": "object",
394 "properties": {
395 "prompt": { "type": "string", "description": "Work prompt for the durable task." },
396 "model": { "type": "string" },
397 "model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
398 "model_provider_id": { "type": "string", "description": "Exact configured provider id." },
399 "workspace": { "type": "string", "description": "Workspace path; defaults to current workspace." },
400 "mode": { "type": "string", "enum": ["agent", "plan", "operate"] },
401 "allow_shell": { "type": "boolean" },
402 "trust_mode": { "type": "boolean" },
403 "auto_approve": { "type": "boolean" }
404 },
405 "required": ["prompt"],
406 "additionalProperties": false
407 }),
408 "list" => json!({
409 "type": "object",
410 "properties": {
411 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
412 },
413 "additionalProperties": false
414 }),
415 "read" | "cancel" => json!({
416 "type": "object",
417 "properties": {
418 "task_id": { "type": "string", "description": "Full task id or unambiguous prefix." }
419 },
420 "required": ["task_id"],
421 "additionalProperties": false
422 }),
423 "gate_run" => json!({
424 "type": "object",
425 "properties": {
426 "gate": {
427 "type": "string",
428 "enum": ["fmt", "check", "clippy", "test", "custom"],
429 "description": "Gate category."
430 },
431 "command": { "type": "string", "description": "Command to run." },
432 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
433 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 }
434 },
435 "required": ["gate", "command"],
436 "additionalProperties": false
437 }),
438 "pr_attempt_record" => json!({
439 "type": "object",
440 "properties": {
441 "task_id": { "type": "string", "description": "Task to attach to; defaults to active task." },
442 "attempt_group_id": { "type": "string" },
443 "attempt_index": { "type": "integer", "minimum": 1 },
444 "attempt_count": { "type": "integer", "minimum": 1 },
445 "summary": { "type": "string" },
446 "verification": { "type": "array", "items": { "type": "string" } }
447 },
448 "required": ["summary"],
449 "additionalProperties": false
450 }),
451 "pr_attempt_list" => task_id_schema(),
452 // pr_attempt_read / pr_attempt_preflight share the attempt-id schema.
453 _ => json!({
454 "type": "object",
455 "properties": {
456 "task_id": { "type": "string", "description": "Task id; defaults to active task." },
457 "attempt_id": { "type": "string" }
458 },
459 "required": ["attempt_id"],
460 "additionalProperties": false
461 }),
462 }
463 }
464
465 impl TasksTool {
466 async fn execute_create(
467 &self,
468 input: &Value,
469 context: &ToolContext,
470 ) -> Result<ToolResult, ToolError> {
471 let manager = context
472 .runtime
473 .task_manager
474 .as_ref()
475 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
476 let workspace = optional_str(input, "workspace")?
477 .map(PathBuf::from)
478 .unwrap_or_else(|| context.workspace.clone());
479 let prompt = required_str(input, "prompt")?.to_string();
480 let req = NewTaskRequest {
481 prompt: prompt.clone(),
482 name: optional_str(input, "name")?.map(ToString::to_string),
483 model: optional_str(input, "model")?.map(ToString::to_string),
484 model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
485 model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
486 workspace: Some(workspace),
487 mode: optional_str(input, "mode")?.map(ToString::to_string),
488 // Authority declarations: read strictly. A malformed value that
489 // silently reads as "unset" is a restriction that evaporates.
490 allow_shell: optional_bool_opt(input, "allow_shell")?,
491 trust_mode: optional_bool_opt(input, "trust_mode")?,
492 auto_approve: optional_bool_opt(input, "auto_approve")?,
493 owner_session_id: Some(context.state_namespace.clone()),
494 };
495 let task_id = crate::task_manager::TaskManager::new_task_id();
496 if let Some(work) = context.runtime.work.as_ref()
497 && let Err(err) = work.register_operation(
498 &context.state_namespace,
499 OperationIntent::new(
500 format!("task:{task_id}"),
501 prompt,
502 true,
503 "task_create",
504 &task_id,
505 ),
506 )
507 {
508 // Bookkeeping must not veto the task: every later reconcile is
509 // guarded by `has_operation_binding`, so an unbound task merely
510 // goes unreported on the Work surface.
511 tracing::warn!(
512 task_id = %task_id,
513 error = %err,
514 "task work-graph registration skipped; running unbound"
515 );
516 }
517 let task = match manager.add_task_with_id(req, task_id.clone()).await {
518 Ok(task) => task,
519 Err(err) => {
520 if let Some(work) = context.runtime.work.as_ref() {
521 let _ = work.reconcile_operation(
522 &context.state_namespace,
523 OperationOwnerSnapshot::new(
524 format!("task:{task_id}"),
525 OwnerState::Failed,
526 1,
527 Utc::now().timestamp_millis(),
528 ),
529 );
530 }
531 return Err(ToolError::execution_failed(err.to_string()));
532 }
533 };
534 let lifecycle_warning = reconcile_task_record(context, &task).err().map(|err| {
535 tracing::warn!(task_id = %task.id, error = %err, "task was created but Work lifecycle reconciliation failed");
536 err.to_string()
537 });
538 task_result_with_lifecycle_warning("task_create", &task, lifecycle_warning.as_deref())
539 }
540
541 async fn execute_list(
542 &self,
543 input: &Value,
544 context: &ToolContext,
545 ) -> Result<ToolResult, ToolError> {
546 let manager = context
547 .runtime
548 .task_manager
549 .as_ref()
550 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
551 let limit = optional_u64(input, "limit", 20)?.clamp(1, 100) as usize;
552 let tasks = manager
553 .list_tasks_for_owner(Some(limit), None, &context.state_namespace)
554 .await
555 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
556 ToolResult::json(&json!({
557 "summary": format!("{} durable task(s)", tasks.len()),
558 "tasks": tasks,
559 }))
560 .map_err(|e| ToolError::execution_failed(e.to_string()))
561 }
562
563 async fn execute_read(
564 &self,
565 input: &Value,
566 context: &ToolContext,
567 ) -> Result<ToolResult, ToolError> {
568 let task = read_task_for_input(input, context).await?;
569 task_result("task_read", &task)
570 }
571
572 async fn execute_cancel(
573 &self,
574 input: &Value,
575 context: &ToolContext,
576 ) -> Result<ToolResult, ToolError> {
577 let manager = context
578 .runtime
579 .task_manager
580 .as_ref()
581 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
582 let task_id = required_str(input, "task_id")?;
583 let cancellation = if context.runtime.active_task_id.as_deref() == Some(task_id) {
584 // `active_task_id` is stamped from the immutable runtime thread
585 // record, not model input. Preserve self-cancel for a running task,
586 // but fail closed for ownerless legacy records.
587 manager.cancel_task_for_active_runtime(task_id).await
588 } else {
589 manager
590 .cancel_task_for_owner(task_id, &context.state_namespace)
591 .await
592 }
593 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
594 let task = cancellation.task;
595 let cancel_outcome = match cancellation.disposition {
596 TaskCancelDisposition::Forced => CancelOutcome::Forced,
597 TaskCancelDisposition::Requested => CancelOutcome::Requested,
598 TaskCancelDisposition::AlreadyFinished => CancelOutcome::AlreadyFinished,
599 };
600 let mut lifecycle_warnings = Vec::new();
601 if let Some(work) = context.runtime.work.as_ref() {
602 let external = format!("task:{}", task.id);
603 if work.has_operation_binding(Some(&context.state_namespace), &external)
604 && let Err(err) = work.reconcile_observation(
605 &context.state_namespace,
606 &external,
607 OperationObservation::CancelUpdate {
608 outcome: cancel_outcome,
609 at: Utc::now().timestamp_millis(),
610 },
611 )
612 {
613 tracing::warn!(task_id = %task.id, error = %err, "task was cancelled but Work cancel reconciliation failed");
614 lifecycle_warnings.push(err);
615 }
616 }
617 if let Err(err) = reconcile_task_record(context, &task) {
618 tracing::warn!(task_id = %task.id, error = %err, "task cancellation succeeded but owner-state reconciliation failed");
619 lifecycle_warnings.push(err.to_string());
620 }
621 let lifecycle_warning =
622 (!lifecycle_warnings.is_empty()).then(|| lifecycle_warnings.join("; "));
623 task_result_with_lifecycle_warning("task_cancel", &task, lifecycle_warning.as_deref())
624 }
625
626 async fn execute_gate_run(
627 &self,
628 input: &Value,
629 context: &ToolContext,
630 ) -> Result<ToolResult, ToolError> {
631 crate::core::engine::tool_catalog::enforce_tool_denial(context, "task_gate_run", input)?;
632 if context.shell_policy != crate::worker_profile::ShellPolicy::Full {
633 return Err(ToolError::permission_denied(
634 "Gate commands require full shell permission.",
635 ));
636 }
637 let gate = required_str(input, "gate")?.to_string();
638 let command = required_str(input, "command")?.to_string();
639 let timeout_ms = optional_u64(input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)?
640 .clamp(1_000, MAX_GATE_TIMEOUT_MS);
641 let cwd = resolve_cwd(context, optional_str(input, "cwd")?)?;
642
643 let safety = analyze_command(&command);
644 if !context.auto_approve && matches!(safety.level, SafetyLevel::Dangerous) {
645 return Ok(ToolResult::error(format!(
646 "BLOCKED: gate command classified dangerous: {}",
647 safety.reasons.join("; ")
648 ))
649 .with_metadata(json!({
650 "safety_level": "dangerous",
651 "blocked": true,
652 "reasons": safety.reasons,
653 })));
654 }
655
656 let started = Instant::now();
657 let mut cmd = build_gate_command(&command, &cwd);
658 let output =
659 tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), cmd.output()).await;
660
661 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
662 let (exit_code, stdout, stderr, timed_out, spawn_error) = match output {
663 Ok(Ok(out)) => (
664 out.status.code(),
665 String::from_utf8_lossy(&out.stdout).to_string(),
666 String::from_utf8_lossy(&out.stderr).to_string(),
667 false,
668 None,
669 ),
670 Ok(Err(err)) => (
671 None,
672 String::new(),
673 String::new(),
674 false,
675 Some(err.to_string()),
676 ),
677 Err(_) => (None, String::new(), String::new(), true, None),
678 };
679
680 let full_log = format!(
681 "$ {command}\n\n[stdout]\n{stdout}\n\n[stderr]\n{stderr}\n{}",
682 spawn_error
683 .as_ref()
684 .map(|e| format!("\n[spawn_error]\n{e}\n"))
685 .unwrap_or_default()
686 );
687 let summary_source = if !stderr.trim().is_empty() {
688 stderr.as_str()
689 } else if !stdout.trim().is_empty() {
690 stdout.as_str()
691 } else {
692 spawn_error.as_deref().unwrap_or("(no output)")
693 };
694 let summary = summarize(summary_source, MAX_SUMMARY_CHARS);
695 let status = if timed_out {
696 "timeout"
697 } else if spawn_error.is_some() {
698 "failed"
699 } else if exit_code == Some(0) {
700 "passed"
701 } else {
702 "failed"
703 };
704 let classification = classify_gate_failure(&gate, status, timed_out, &stderr, &stdout);
705 let log_path = write_runtime_artifact(context, "gate", &full_log).await?;
706 let gate_record = TaskGateRecord {
707 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
708 gate: gate.clone(),
709 command: command.clone(),
710 cwd: cwd.clone(),
711 exit_code,
712 status: status.to_string(),
713 classification,
714 duration_ms,
715 summary: summary.clone(),
716 log_path: log_path.clone(),
717 recorded_at: Utc::now(),
718 };
719
720 let content = json!({
721 "gate": gate_record,
722 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
723 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
724 });
725 let mut metadata = json!({
726 "command": command,
727 "cwd": cwd,
728 "exit_code": exit_code,
729 "duration_ms": duration_ms,
730 "timed_out": timed_out,
731 "task_updates": {
732 "gate": gate_record,
733 "artifacts": artifact_updates("gate_log", log_path.clone(), &summary)
734 }
735 });
736 if let Some(path) = log_path {
737 metadata["artifact_path"] = json!(path);
738 }
739 Ok(ToolResult::json(&content)
740 .map_err(|e| ToolError::execution_failed(e.to_string()))?
741 .with_metadata(metadata))
742 }
743
744 async fn execute_pr_attempt_record(
745 &self,
746 input: &Value,
747 context: &ToolContext,
748 ) -> Result<ToolResult, ToolError> {
749 let task_id = read_task_for_input(input, context).await?.id;
750 let base_sha = git_output(&context.workspace, &["rev-parse", "HEAD"])
751 .await
752 .ok();
753 let head_sha = base_sha.clone();
754 let branch = git_output(&context.workspace, &["rev-parse", "--abbrev-ref", "HEAD"])
755 .await
756 .ok();
757 let diff = git_output(&context.workspace, &["diff", "--binary", "--no-color"]).await?;
758 if diff.trim().is_empty() {
759 return Ok(ToolResult::error(
760 "No working-tree diff to record as an attempt.",
761 ));
762 }
763 let changed_files = git_output(&context.workspace, &["diff", "--name-only"])
764 .await?
765 .lines()
766 .filter(|line| !line.trim().is_empty())
767 .map(ToString::to_string)
768 .collect::<Vec<_>>();
769 let patch_path = write_task_artifact_for(context, &task_id, "attempt_patch", &diff).await?;
770 let attempt = TaskAttemptRecord {
771 id: format!("attempt_{}", &Uuid::new_v4().to_string()[..8]),
772 attempt_group_id: optional_str(input, "attempt_group_id")?
773 .map(ToString::to_string)
774 .unwrap_or_else(|| format!("attempt_group_{}", &Uuid::new_v4().to_string()[..8])),
775 attempt_index: optional_u64(input, "attempt_index", 1)?.max(1) as u32,
776 attempt_count: optional_u64(input, "attempt_count", 1)?.max(1) as u32,
777 base_ref: branch.clone(),
778 base_sha,
779 head_ref: branch,
780 head_sha,
781 summary: required_str(input, "summary")?.to_string(),
782 changed_files,
783 patch_path: patch_path.clone(),
784 verification: input
785 .get("verification")
786 .and_then(Value::as_array)
787 .map(|items| {
788 items
789 .iter()
790 .filter_map(Value::as_str)
791 .map(ToString::to_string)
792 .collect()
793 })
794 .unwrap_or_default(),
795 selected: false,
796 recorded_at: Utc::now(),
797 };
798 let metadata = json!({
799 "task_id": task_id,
800 "task_updates": {
801 "attempt": attempt,
802 "artifacts": artifact_updates("attempt_patch", patch_path.clone(), "Captured git diff for PR attempt")
803 }
804 });
805 if context.runtime.active_task_id.as_deref() != Some(task_id.as_str())
806 && let Some(manager) = context.runtime.task_manager.as_ref()
807 {
808 manager
809 .record_tool_metadata(&task_id, &metadata)
810 .await
811 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
812 }
813 Ok(ToolResult::json(&metadata)
814 .map_err(|e| ToolError::execution_failed(e.to_string()))?
815 .with_metadata(metadata))
816 }
817
818 async fn execute_pr_attempt_list(
819 &self,
820 input: &Value,
821 context: &ToolContext,
822 ) -> Result<ToolResult, ToolError> {
823 let task = read_task_for_input(input, context).await?;
824 ToolResult::json(&json!({ "task_id": task.id, "attempts": task.attempts }))
825 .map_err(|e| ToolError::execution_failed(e.to_string()))
826 }
827
828 async fn execute_pr_attempt_read(
829 &self,
830 input: &Value,
831 context: &ToolContext,
832 ) -> Result<ToolResult, ToolError> {
833 let task = read_task_for_input(input, context).await?;
834 let attempt_id = required_str(input, "attempt_id")?;
835 let attempt = task
836 .attempts
837 .iter()
838 .find(|attempt| attempt.id == attempt_id)
839 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
840 ToolResult::json(attempt).map_err(|e| ToolError::execution_failed(e.to_string()))
841 }
842
843 async fn execute_pr_attempt_preflight(
844 &self,
845 input: &Value,
846 context: &ToolContext,
847 ) -> Result<ToolResult, ToolError> {
848 let manager = context
849 .runtime
850 .task_manager
851 .as_ref()
852 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
853 let task = read_task_for_input(input, context).await?;
854 let attempt_id = required_str(input, "attempt_id")?;
855 let attempt = task
856 .attempts
857 .iter()
858 .find(|attempt| attempt.id == attempt_id)
859 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
860 let patch_ref = attempt
861 .patch_path
862 .as_ref()
863 .ok_or_else(|| ToolError::invalid_input("Attempt has no patch artifact"))?;
864 let patch_path = manager.artifact_absolute_path(patch_ref);
865 let workspace = context.workspace.clone();
866 let out = tokio::task::spawn_blocking(move || {
867 crate::dependencies::Git::command()
868 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "git not found"))?
869 .args(["apply", "--check"])
870 .arg(&patch_path)
871 .current_dir(&workspace)
872 .output()
873 })
874 .await
875 .map_err(|join_err| {
876 // Surface the otherwise-discarded join error for debugging; the
877 // returned ToolError (and thus user-facing behavior) is unchanged.
878 tracing::debug!(error = %join_err, "git apply --check spawn_blocking task failed to join");
879 ToolError::execution_failed(format!("git apply --check panicked: {join_err}"))
880 })?
881 .map_err(|e| ToolError::execution_failed(format!("git apply --check failed: {e}")))?;
882 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
883 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
884 ToolResult::json(&json!({
885 "attempt_id": attempt_id,
886 "patch_path": patch_ref,
887 "would_apply": out.status.success(),
888 "exit_code": out.status.code(),
889 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
890 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
891 "mutated_worktree": false
892 }))
893 .map_err(|e| ToolError::execution_failed(e.to_string()))
894 }
895 }
896
897 #[async_trait]
898 impl ToolSpec for TaskShellStartTool {
899 fn name(&self) -> &'static str {
900 "task_shell_start"
901 }
902
903 fn description(&self) -> &'static str {
904 "Start a long-running shell command in the background and return a shell task_id immediately. Completion is delivered automatically as an internal runtime event and remains visible in the task/status surface; use task_shell_wait only for early output, explicit barriers, or gate evidence on the active durable task."
905 }
906
907 fn input_schema(&self) -> Value {
908 json!({
909 "type": "object",
910 "properties": {
911 "command": { "type": "string" },
912 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
913 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
914 "stdin": { "type": "string" },
915 "tty": { "type": "boolean" }
916 },
917 "required": ["command"],
918 "additionalProperties": false
919 })
920 }
921
922 fn capabilities(&self) -> Vec<ToolCapability> {
923 vec![
924 ToolCapability::ExecutesCode,
925 ToolCapability::RequiresApproval,
926 ]
927 }
928
929 fn approval_requirement(&self) -> ApprovalRequirement {
930 ApprovalRequirement::Required
931 }
932
933 fn starts_detached_for(&self, input: &Value) -> bool {
934 input.get("command").and_then(Value::as_str).is_some()
935 }
936
937 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
938 crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
939 let mut shell_input = json!({
940 "command": required_str(&input, "command")?,
941 "background": true,
942 "timeout_ms": optional_u64(&input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)?
943 .clamp(1_000, MAX_GATE_TIMEOUT_MS),
944 });
945 if let Some(cwd) = optional_str(&input, "cwd")? {
946 let cwd = resolve_cwd(context, Some(cwd))?;
947 shell_input["cwd"] = json!(cwd);
948 }
949 if let Some(stdin) = optional_str(&input, "stdin")? {
950 shell_input["stdin"] = json!(stdin);
951 }
952 if optional_bool(&input, "tty", false)? {
953 shell_input["tty"] = json!(true);
954 }
955 let mut result = BashTool::new("Bash").execute(shell_input, context).await?;
956 if let Some(metadata) = result.metadata.as_mut() {
957 metadata["background"] = json!(true);
958 metadata["task_shell"] = json!(true);
959 }
960 Ok(result)
961 }
962 }
963
964 #[async_trait]
965 impl ToolSpec for TaskShellWaitTool {
966 fn name(&self) -> &'static str {
967 "task_shell_wait"
968 }
969
970 fn description(&self) -> &'static str {
971 "Poll a background shell task without blocking the agent indefinitely. Completion is delivered automatically; use this only for early output, explicit barriers, or gate evidence. If `gate` is supplied and the shell task has completed, records structured gate evidence on the active durable task."
972 }
973
974 fn input_schema(&self) -> Value {
975 json!({
976 "type": "object",
977 "properties": {
978 "task_id": { "type": "string", "description": "Background shell task id returned by task_shell_start or `Bash`." },
979 "wait": { "type": "boolean", "default": false },
980 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
981 "gate": { "type": "string", "enum": ["fmt", "check", "clippy", "test", "custom"] },
982 "command": { "type": "string", "description": "Original command, used when recording gate evidence." }
983 },
984 "required": ["task_id"],
985 "additionalProperties": false
986 })
987 }
988
989 fn capabilities(&self) -> Vec<ToolCapability> {
990 vec![ToolCapability::ReadOnly]
991 }
992
993 fn approval_requirement(&self) -> ApprovalRequirement {
994 ApprovalRequirement::Auto
995 }
996
997 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
998 crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
999 let shell_input = task_shell_wait_input(input.clone());
1000 let result = BashTool::alias("exec_shell_wait", "wait")
1001 .execute(shell_input, context)
1002 .await?;
1003 let Some(gate) = optional_str(&input, "gate")? else {
1004 return Ok(result);
1005 };
1006 let status = result
1007 .metadata
1008 .as_ref()
1009 .and_then(|m| m.get("status"))
1010 .and_then(Value::as_str)
1011 .unwrap_or("Running");
1012 if status == "Running" {
1013 return Ok(result);
1014 }
1015 let exit_code = result
1016 .metadata
1017 .as_ref()
1018 .and_then(|m| m.get("exit_code"))
1019 .and_then(Value::as_i64)
1020 .and_then(|v| i32::try_from(v).ok());
1021 let duration_ms = result
1022 .metadata
1023 .as_ref()
1024 .and_then(|m| m.get("duration_ms"))
1025 .and_then(Value::as_u64)
1026 .unwrap_or_default();
1027 let command = optional_str(&input, "command")?.unwrap_or("(background shell)");
1028 let log_path = write_runtime_artifact(context, "background_gate", &result.content).await?;
1029 let gate_status = if exit_code == Some(0) {
1030 "passed"
1031 } else if status == "TimedOut" {
1032 "timeout"
1033 } else {
1034 "failed"
1035 };
1036 let gate_record = TaskGateRecord {
1037 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
1038 gate: gate.to_string(),
1039 command: command.to_string(),
1040 cwd: context.workspace.clone(),
1041 exit_code,
1042 status: gate_status.to_string(),
1043 classification: classify_gate_failure(
1044 gate,
1045 gate_status,
1046 status == "TimedOut",
1047 &result.content,
1048 "",
1049 ),
1050 duration_ms,
1051 summary: summarize(&result.content, MAX_SUMMARY_CHARS),
1052 log_path: log_path.clone(),
1053 recorded_at: Utc::now(),
1054 };
1055 let mut metadata = result.metadata.clone().unwrap_or_else(|| json!({}));
1056 metadata["background"] = json!(true);
1057 metadata["task_updates"] = json!({
1058 "gate": gate_record,
1059 "artifacts": artifact_updates("background_gate_log", log_path, "Background shell gate output")
1060 });
1061 Ok(result.with_metadata(metadata))
1062 }
1063 }
1064
1065 fn reconcile_task_record(context: &ToolContext, task: &TaskRecord) -> Result<(), ToolError> {
1066 let Some(work) = context.runtime.work.as_ref() else {
1067 return Ok(());
1068 };
1069 let external = format!("task:{}", task.id);
1070 if !work.has_operation_binding(Some(&context.state_namespace), &external) {
1071 return Ok(());
1072 }
1073 work.reconcile_operation(
1074 &context.state_namespace,
1075 task_owner_snapshot(
1076 &task.id,
1077 task.status,
1078 task.lifecycle_seq,
1079 task.created_at,
1080 task.started_at,
1081 task.ended_at,
1082 ),
1083 )
1084 .map(|_| ())
1085 .map_err(ToolError::execution_failed)
1086 }
1087
1088 fn task_result(label: &str, task: &TaskRecord) -> Result<ToolResult, ToolError> {
1089 task_result_with_lifecycle_warning(label, task, None)
1090 }
1091
1092 fn task_result_with_lifecycle_warning(
1093 label: &str,
1094 task: &TaskRecord,
1095 lifecycle_warning: Option<&str>,
1096 ) -> Result<ToolResult, ToolError> {
1097 ToolResult::json(&json!({
1098 "summary": format!("{label}: {} ({:?})", task.id, task.status),
1099 "task": task,
1100 "lifecycle_warning": lifecycle_warning,
1101 "execution_ownership": if task.execution_scope.is_some() { "scope_bound" } else { "unverified" },
1102 }))
1103 .map_err(|e| ToolError::execution_failed(e.to_string()))
1104 }
1105
1106 fn resolve_cwd(context: &ToolContext, raw: Option<&str>) -> Result<PathBuf, ToolError> {
1107 match raw {
1108 Some(path) => {
1109 let resolved = context.resolve_path(path)?;
1110 if resolved.is_dir() {
1111 Ok(resolved)
1112 } else {
1113 Err(ToolError::invalid_input(format!(
1114 "cwd must be a directory: {path}"
1115 )))
1116 }
1117 }
1118 None => Ok(context.workspace.clone()),
1119 }
1120 }
1121
1122 async fn write_runtime_artifact(
1123 context: &ToolContext,
1124 label: &str,
1125 content: &str,
1126 ) -> Result<Option<PathBuf>, ToolError> {
1127 let Some(task_id) = context.runtime.active_task_id.as_deref() else {
1128 return Ok(None);
1129 };
1130 let manager = context.runtime.task_manager.as_ref();
1131 if let Some(manager) = manager {
1132 return manager
1133 .write_task_artifact(task_id, label, content)
1134 .map(Some)
1135 .map_err(|e| ToolError::execution_failed(e.to_string()));
1136 }
1137 let Some(data_dir) = context.runtime.task_data_dir.as_ref() else {
1138 return Ok(None);
1139 };
1140 let artifact_dir = data_dir.join("artifacts").join(task_id);
1141 let filename = format!(
1142 "{}_{}.txt",
1143 Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
1144 sanitize_filename(label)
1145 );
1146 let absolute = artifact_dir.join(filename);
1147 let content_owned = content.to_owned();
1148 let abs = absolute.clone();
1149 tokio::task::spawn_blocking(move || {
1150 std::fs::create_dir_all(&artifact_dir)?;
1151 std::fs::write(&abs, content_owned)?;
1152 Ok::<(), std::io::Error>(())
1153 })
1154 .await
1155 .map_err(|e| {
1156 // Surface the otherwise-discarded join error for debugging; the
1157 // returned ToolError (and thus user-facing behavior) is unchanged.
1158 tracing::debug!(error = %e, "artifact write spawn_blocking task failed to join");
1159 ToolError::execution_failed(format!("artifact write task panicked: {e}"))
1160 })?
1161 .map_err(|e| ToolError::execution_failed(format!("write artifact: {e}")))?;
1162 Ok(Some(
1163 absolute
1164 .strip_prefix(data_dir)
1165 .map(PathBuf::from)
1166 .unwrap_or(absolute),
1167 ))
1168 }
1169
1170 async fn write_task_artifact_for(
1171 context: &ToolContext,
1172 task_id: &str,
1173 label: &str,
1174 content: &str,
1175 ) -> Result<Option<PathBuf>, ToolError> {
1176 if let Some(manager) = context.runtime.task_manager.as_ref() {
1177 return manager
1178 .write_task_artifact(task_id, label, content)
1179 .map(Some)
1180 .map_err(|e| ToolError::execution_failed(e.to_string()));
1181 }
1182 if context.runtime.active_task_id.as_deref() != Some(task_id) {
1183 return Ok(None);
1184 }
1185 write_runtime_artifact(context, label, content).await
1186 }
1187
1188 fn artifact_updates(label: &str, path: Option<PathBuf>, summary: &str) -> Value {
1189 match path {
1190 Some(path) => json!([TaskArtifactRef {
1191 label: label.to_string(),
1192 path,
1193 summary: summarize(summary, 240),
1194 created_at: Utc::now(),
1195 }]),
1196 None => json!([]),
1197 }
1198 }
1199
1200 async fn read_task_for_input(
1201 input: &Value,
1202 context: &ToolContext,
1203 ) -> Result<TaskRecord, ToolError> {
1204 let manager = context
1205 .runtime
1206 .task_manager
1207 .as_ref()
1208 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
1209 let task_id = task_id_from_input_or_context(input, context)?;
1210 if context.runtime.active_task_id.as_deref() == Some(task_id.as_str()) {
1211 // Runtime thread construction stamps `active_task_id` from its durable
1212 // thread record. It is not a tool parameter, so an owned task may read
1213 // itself even though its execution engine has a distinct namespace.
1214 manager
1215 .get_task_for_active_runtime(&task_id)
1216 .await
1217 .map_err(|e| ToolError::execution_failed(e.to_string()))
1218 } else {
1219 manager
1220 .get_task_for_owner(&task_id, &context.state_namespace)
1221 .await
1222 .map_err(|e| ToolError::execution_failed(e.to_string()))
1223 }
1224 }
1225
1226 fn task_id_from_input_or_context(
1227 input: &Value,
1228 context: &ToolContext,
1229 ) -> Result<String, ToolError> {
1230 optional_str(input, "task_id")?
1231 .map(ToString::to_string)
1232 .or_else(|| context.runtime.active_task_id.clone())
1233 .ok_or_else(|| {
1234 ToolError::invalid_input("task_id is required when no durable task is active")
1235 })
1236 }
1237
1238 fn task_id_schema() -> Value {
1239 json!({
1240 "type": "object",
1241 "properties": {
1242 "task_id": { "type": "string", "description": "Task id; defaults to active task." }
1243 },
1244 "additionalProperties": false
1245 })
1246 }
1247
1248 async fn git_output(workspace: &Path, args: &[&str]) -> Result<String, ToolError> {
1249 let args_owned: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
1250 let cwd = workspace.to_path_buf();
1251 let out = tokio::task::spawn_blocking(move || {
1252 let arg_refs: Vec<&str> = args_owned.iter().map(String::as_str).collect();
1253 crate::dependencies::Git::output(&arg_refs, &cwd)
1254 })
1255 .await
1256 .map_err(|e| {
1257 // Surface the otherwise-discarded join error for debugging; the
1258 // returned ToolError (and thus user-facing behavior) is unchanged.
1259 tracing::debug!(error = %e, "git spawn_blocking task failed to join");
1260 ToolError::execution_failed(format!("git task panicked: {e}"))
1261 })?
1262 .map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
1263 if !out.status.success() {
1264 return Err(ToolError::execution_failed(format!(
1265 "git {} failed: {}",
1266 args.join(" "),
1267 String::from_utf8_lossy(&out.stderr).trim()
1268 )));
1269 }
1270 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
1271 }
1272
1273 fn classify_gate_failure(
1274 gate: &str,
1275 status: &str,
1276 timed_out: bool,
1277 stderr: &str,
1278 stdout: &str,
1279 ) -> String {
1280 if timed_out {
1281 return "timeout".to_string();
1282 }
1283 if status == "passed" {
1284 return "passed".to_string();
1285 }
1286 let haystack = format!("{stderr}\n{stdout}").to_ascii_lowercase();
1287 if haystack.contains("address already in use") || haystack.contains("port") {
1288 "environment_port_binding".to_string()
1289 } else if gate == "clippy" || haystack.contains("warning:") {
1290 "lint_failure".to_string()
1291 } else if gate == "test" || haystack.contains("test result: failed") {
1292 "test_failure".to_string()
1293 } else if haystack.contains("error: could not compile")
1294 || haystack.contains("compilation failed")
1295 {
1296 "compile_error".to_string()
1297 } else {
1298 "environment_or_tooling_failure".to_string()
1299 }
1300 }
1301
1302 fn summarize(text: &str, limit: usize) -> String {
1303 let mut out = String::new();
1304 for (idx, ch) in text.chars().enumerate() {
1305 if idx >= limit.saturating_sub(3) {
1306 out.push_str("...");
1307 return out;
1308 }
1309 if ch.is_control() && ch != '\n' && ch != '\t' {
1310 continue;
1311 }
1312 out.push(ch);
1313 }
1314 if out.trim().is_empty() {
1315 "(no output)".to_string()
1316 } else {
1317 out
1318 }
1319 }
1320
1321 fn sanitize_filename(input: &str) -> String {
1322 let mut out = String::new();
1323 for ch in input.chars() {
1324 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
1325 out.push(ch);
1326 } else {
1327 out.push('_');
1328 }
1329 }
1330 if out.is_empty() {
1331 "artifact".to_string()
1332 } else {
1333 out
1334 }
1335 }
1336
1337 #[cfg(test)]
1338 mod tests {
1339 use super::*;
1340 use crate::tools::spec::ToolSpec;
1341
1342 #[test]
1343 fn durable_task_schema_requires_prompt() {
1344 let schema = TasksTool::alias("task_create", "create").input_schema();
1345 assert_eq!(schema["required"][0], "prompt");
1346 assert!(schema["properties"]["prompt"].is_object());
1347 }
1348
1349 #[test]
1350 fn create_mode_enum_advertises_operate_not_yolo() {
1351 let create = TasksTool::alias("task_create", "create").input_schema();
1352 assert_eq!(
1353 create["properties"]["mode"]["enum"],
1354 json!(["agent", "plan", "operate"])
1355 );
1356 let canonical = TasksTool::new("tasks").input_schema();
1357 assert_eq!(
1358 canonical["properties"]["mode"]["enum"],
1359 json!(["agent", "plan", "operate"])
1360 );
1361 }
1362
1363 #[test]
1364 fn gate_classifier_detects_timeout() {
1365 assert_eq!(
1366 classify_gate_failure("test", "timeout", true, "", ""),
1367 "timeout"
1368 );
1369 }
1370
1371 #[test]
1372 fn canonical_schema_lists_all_actions_and_union_fields() {
1373 let schema = TasksTool::new("tasks").input_schema();
1374 let actions = schema["properties"]["action"]["enum"]
1375 .as_array()
1376 .expect("action enum");
1377 for action in [
1378 "create",
1379 "list",
1380 "read",
1381 "cancel",
1382 "gate_run",
1383 "pr_attempt_record",
1384 "pr_attempt_list",
1385 "pr_attempt_read",
1386 "pr_attempt_preflight",
1387 ] {
1388 assert!(
1389 actions.iter().any(|value| value.as_str() == Some(action)),
1390 "canonical schema must offer action {action}"
1391 );
1392 }
1393 for field in [
1394 "prompt",
1395 "task_id",
1396 "gate",
1397 "command",
1398 "attempt_id",
1399 "limit",
1400 ] {
1401 assert!(
1402 schema["properties"][field].is_object(),
1403 "canonical schema must carry union field {field}"
1404 );
1405 }
1406 assert_eq!(schema["additionalProperties"], json!(false));
1407 }
1408
1409 #[test]
1410 fn read_only_variant_only_offers_read_actions() {
1411 let tool = TasksTool::read_only("tasks");
1412 let schema = tool.input_schema();
1413 assert_eq!(
1414 schema["properties"]["action"]["enum"],
1415 json!(["list", "read", "pr_attempt_list", "pr_attempt_read"])
1416 );
1417 assert!(!schema["properties"]["prompt"].is_object());
1418 assert!(!schema["properties"]["gate"].is_object());
1419 // pr_attempt_read is a read action: its id field must be advertised
1420 // on the read-only surface too.
1421 assert!(schema["properties"]["attempt_id"].is_object());
1422 assert!(schema["properties"]["task_id"].is_object());
1423 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
1424 assert!(tool.is_read_only());
1425 assert_eq!(tool.capabilities(), vec![ToolCapability::ReadOnly]);
1426 }
1427
1428 #[test]
1429 fn aliases_hide_from_model_and_force_action() {
1430 let create = TasksTool::alias("task_create", "create");
1431 assert!(!create.model_visible());
1432 assert_eq!(create.name(), "task_create");
1433 assert_eq!(create.approval_requirement(), ApprovalRequirement::Required);
1434
1435 let gate = TasksTool::alias("task_gate_run", "gate_run");
1436 assert_eq!(gate.approval_requirement(), ApprovalRequirement::Required);
1437 assert!(gate.capabilities().contains(&ToolCapability::ExecutesCode));
1438
1439 let list = TasksTool::alias("task_list", "list");
1440 assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto);
1441 assert!(list.is_read_only_for(&json!({})));
1442
1443 let canonical = TasksTool::new("tasks");
1444 assert!(canonical.model_visible());
1445 assert_eq!(
1446 canonical.approval_requirement_for(&json!({"action": "list"})),
1447 ApprovalRequirement::Auto
1448 );
1449 assert_eq!(
1450 canonical.approval_requirement_for(&json!({"action": "cancel"})),
1451 ApprovalRequirement::Required
1452 );
1453 assert_eq!(
1454 canonical.approval_requirement_for(&json!({"action": "gate_run"})),
1455 ApprovalRequirement::Required
1456 );
1457 assert!(canonical.is_read_only_for(&json!({"action": "pr_attempt_read"})));
1458 assert!(!canonical.is_read_only_for(&json!({"action": "create"})));
1459 }
1460
1461 #[test]
1462 fn canonical_rejects_unknown_or_missing_action() {
1463 let tool = TasksTool::new("tasks");
1464 let err = tool
1465 .resolve_action(&json!({}))
1466 .expect_err("missing action must fail");
1467 assert!(err.to_string().contains("missing `action`"));
1468 let err = tool
1469 .resolve_action(&json!({"action": "explode"}))
1470 .expect_err("unknown action must fail");
1471 assert!(err.to_string().contains("invalid action"));
1472
1473 let read_only = TasksTool::read_only("tasks");
1474 let err = read_only
1475 .resolve_action(&json!({"action": "gate_run"}))
1476 .expect_err("read-only surface must reject exec actions");
1477 assert!(err.to_string().contains("invalid action"));
1478 }
1479
1480 #[test]
1481 fn background_shell_schema_is_explicit() {
1482 let schema = TaskShellStartTool.input_schema();
1483 assert_eq!(schema["required"][0], "command");
1484 assert_eq!(schema["properties"]["timeout_ms"]["maximum"], 600000);
1485
1486 let wait_schema = TaskShellWaitTool.input_schema();
1487 assert_eq!(wait_schema["required"][0], "task_id");
1488 assert!(wait_schema["properties"]["gate"].is_object());
1489 }
1490
1491 #[test]
1492 fn task_shell_wait_keeps_its_documented_nonblocking_default() {
1493 assert_eq!(
1494 task_shell_wait_input(json!({"task_id": "shell_1"}))["wait"],
1495 false
1496 );
1497 assert_eq!(
1498 task_shell_wait_input(json!({"task_id": "shell_1", "wait": true}))["wait"],
1499 true
1500 );
1501 assert_eq!(
1502 task_shell_wait_input(json!({"task_id": "shell_1", "block": true}))["block"],
1503 true
1504 );
1505 assert_eq!(
1506 task_shell_wait_input(json!({"task_id": "shell_1", "wait": null}))["wait"],
1507 false
1508 );
1509 assert_eq!(
1510 task_shell_wait_input(json!({"task_id": "shell_1", "block": null}))["wait"],
1511 false
1512 );
1513 }
1514
1515 #[tokio::test]
1516 async fn task_shell_wait_null_is_a_nonblocking_snapshot() {
1517 let workspace = tempfile::tempdir().expect("workspace");
1518 let context = ToolContext::new(workspace.path());
1519 let started = TaskShellStartTool
1520 .execute(json!({"command": "sleep 2", "timeout_ms": 5_000}), &context)
1521 .await
1522 .expect("start background shell");
1523 let task_id = started
1524 .metadata
1525 .as_ref()
1526 .and_then(|metadata| metadata.get("task_id"))
1527 .and_then(Value::as_str)
1528 .expect("task id")
1529 .to_string();
1530
1531 let before = std::time::Instant::now();
1532 let snapshot = TaskShellWaitTool
1533 .execute(
1534 json!({"task_id": task_id, "wait": null, "timeout_ms": 5_000}),
1535 &context,
1536 )
1537 .await
1538 .expect("poll background shell");
1539 assert!(
1540 before.elapsed() < std::time::Duration::from_secs(1),
1541 "task_shell_wait wait:null must preserve the nonblocking default"
1542 );
1543 assert_eq!(
1544 snapshot
1545 .metadata
1546 .as_ref()
1547 .and_then(|metadata| metadata.get("status"))
1548 .and_then(Value::as_str),
1549 Some("Running")
1550 );
1551
1552 BashTool::alias("exec_shell_cancel", "cancel")
1553 .execute(json!({"task_id": task_id}), &context)
1554 .await
1555 .expect("cancel background shell");
1556 }
1557
1558 #[test]
1559 fn gate_command_uses_login_shell_invocation() {
1560 let (program, args) = build_gate_command_parts("echo hello");
1561 assert_eq!(program, "/bin/sh");
1562 assert_eq!(args, vec!["-lc".to_string(), "echo hello".to_string()]);
1563 }
1564 }
1565
1565 lines RUST