返回 DeepSeek-TUI-2026
task_manager.rs
根目录 / crates / tui / src / task_manager.rs
1 //! Persistent background task manager for DeepSeek agent work.
2 //!
3 //! Tasks are durable across restarts and execute with a bounded worker pool.
4 //! Execution stays DeepSeek-only and now links every task to runtime
5 //! thread/turn records for unified timelines.
6
7 use std::collections::{HashMap, HashSet, VecDeque};
8 use std::fs;
9 use std::path::{Path, PathBuf};
10 use std::sync::Arc;
11 use std::time::Duration;
12 #[cfg(test)]
13 use std::time::Duration as StdDuration;
14
15 use anyhow::{Context, Result, anyhow, bail};
16 use async_trait::async_trait;
17 use chrono::{DateTime, Utc};
18 use serde::{Deserialize, Serialize};
19 use serde_json::{Value, json};
20 use tokio::sync::{Mutex, Notify, mpsc};
21 use tokio::time::sleep;
22 use tokio_util::sync::CancellationToken;
23 use uuid::Uuid;
24
25 use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
26 use crate::runtime_threads::{
27 CreateThreadRequest, RuntimeThreadManager, RuntimeThreadManagerConfig, RuntimeTurnStatus,
28 SharedRuntimeThreadManager, StartTurnRequest,
29 };
30 use crate::utils::spawn_supervised;
31
32 const DEFAULT_WORKERS: usize = 2;
33 const MAX_WORKERS: usize = 8;
34 const TIMELINE_SUMMARY_LIMIT: usize = 240;
35 const ARTIFACT_THRESHOLD: usize = 1200;
36 const CURRENT_TASK_SCHEMA_VERSION: u32 = 2;
37
38 const fn default_task_schema_version() -> u32 {
39 CURRENT_TASK_SCHEMA_VERSION
40 }
41
42 /// Durable task status.
43 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44 #[serde(rename_all = "snake_case")]
45 pub enum TaskStatus {
46 Queued,
47 Running,
48 Completed,
49 Failed,
50 Canceled,
51 }
52
53 impl TaskStatus {
54 #[cfg(test)]
55 #[must_use]
56 pub fn is_terminal(self) -> bool {
57 matches!(self, Self::Completed | Self::Failed | Self::Canceled)
58 }
59 }
60
61 /// Durable tool-call status within a task timeline.
62 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
63 #[serde(rename_all = "snake_case")]
64 pub enum TaskToolStatus {
65 Running,
66 Success,
67 Failed,
68 Canceled,
69 }
70
71 /// Timeline entry for a task execution.
72 #[derive(Debug, Clone, Serialize, Deserialize)]
73 pub struct TaskTimelineEntry {
74 pub timestamp: DateTime<Utc>,
75 pub kind: String,
76 pub summary: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub detail_path: Option<PathBuf>,
79 }
80
81 /// Tool call summary for a task.
82 #[derive(Debug, Clone, Serialize, Deserialize)]
83 pub struct TaskToolCallSummary {
84 pub id: String,
85 pub name: String,
86 pub status: TaskToolStatus,
87 pub started_at: DateTime<Utc>,
88 pub ended_at: Option<DateTime<Utc>>,
89 pub duration_ms: Option<u64>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub input_summary: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub output_summary: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub detail_path: Option<PathBuf>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub patch_ref: Option<PathBuf>,
98 }
99
100 /// Checklist item stored on durable tasks. This is the durable form behind the
101 /// model-visible checklist/todo compatibility tools.
102 #[derive(Debug, Clone, Serialize, Deserialize)]
103 pub struct TaskChecklistItem {
104 pub id: u32,
105 pub content: String,
106 pub status: String,
107 }
108
109 /// Checklist state associated with a task.
110 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
111 pub struct TaskChecklistState {
112 pub items: Vec<TaskChecklistItem>,
113 pub completion_pct: u8,
114 pub in_progress_id: Option<u32>,
115 pub updated_at: Option<DateTime<Utc>>,
116 }
117
118 /// Structured verification evidence attached to a task.
119 #[derive(Debug, Clone, Serialize, Deserialize)]
120 pub struct TaskGateRecord {
121 pub id: String,
122 pub gate: String,
123 pub command: String,
124 pub cwd: PathBuf,
125 pub exit_code: Option<i32>,
126 pub status: String,
127 pub classification: String,
128 pub duration_ms: u64,
129 pub summary: String,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub log_path: Option<PathBuf>,
132 pub recorded_at: DateTime<Utc>,
133 }
134
135 /// PR-attempt metadata and artifacts attached to a task.
136 #[derive(Debug, Clone, Serialize, Deserialize)]
137 pub struct TaskAttemptRecord {
138 pub id: String,
139 pub attempt_group_id: String,
140 pub attempt_index: u32,
141 pub attempt_count: u32,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub base_ref: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub base_sha: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub head_ref: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub head_sha: Option<String>,
150 pub summary: String,
151 pub changed_files: Vec<String>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub patch_path: Option<PathBuf>,
154 pub verification: Vec<String>,
155 pub selected: bool,
156 pub recorded_at: DateTime<Utc>,
157 }
158
159 /// Durable artifact reference produced by task-aware tools.
160 #[derive(Debug, Clone, Serialize, Deserialize)]
161 pub struct TaskArtifactRef {
162 pub label: String,
163 pub path: PathBuf,
164 pub summary: String,
165 pub created_at: DateTime<Utc>,
166 }
167
168 /// GitHub write/read evidence attached to a task timeline.
169 #[derive(Debug, Clone, Serialize, Deserialize)]
170 pub struct TaskGithubEvent {
171 pub id: String,
172 pub action: String,
173 pub target: String,
174 pub number: u64,
175 pub summary: String,
176 pub url: Option<String>,
177 pub recorded_at: DateTime<Utc>,
178 }
179
180 /// Durable task record.
181 #[derive(Debug, Clone, Serialize, Deserialize)]
182 pub struct TaskRecord {
183 #[serde(default = "default_task_schema_version")]
184 pub schema_version: u32,
185 pub id: String,
186 pub prompt: String,
187 pub model: String,
188 pub workspace: PathBuf,
189 pub mode: String,
190 pub allow_shell: bool,
191 pub trust_mode: bool,
192 #[serde(default = "default_auto_approve")]
193 pub auto_approve: bool,
194 pub status: TaskStatus,
195 pub created_at: DateTime<Utc>,
196 pub started_at: Option<DateTime<Utc>>,
197 pub ended_at: Option<DateTime<Utc>>,
198 pub duration_ms: Option<u64>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub result_summary: Option<String>,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub result_detail_path: Option<PathBuf>,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub error: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub thread_id: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub turn_id: Option<String>,
209 #[serde(default)]
210 pub runtime_event_count: usize,
211 #[serde(default)]
212 pub checklist: TaskChecklistState,
213 #[serde(default)]
214 pub gates: Vec<TaskGateRecord>,
215 #[serde(default)]
216 pub attempts: Vec<TaskAttemptRecord>,
217 #[serde(default)]
218 pub artifacts: Vec<TaskArtifactRef>,
219 #[serde(default)]
220 pub github_events: Vec<TaskGithubEvent>,
221 pub tool_calls: Vec<TaskToolCallSummary>,
222 pub timeline: Vec<TaskTimelineEntry>,
223 }
224
225 /// Lightweight task view.
226 #[derive(Debug, Clone, Serialize, Deserialize)]
227 pub struct TaskSummary {
228 pub id: String,
229 pub status: TaskStatus,
230 pub prompt_summary: String,
231 pub model: String,
232 pub mode: String,
233 pub created_at: DateTime<Utc>,
234 pub started_at: Option<DateTime<Utc>>,
235 pub ended_at: Option<DateTime<Utc>>,
236 pub duration_ms: Option<u64>,
237 #[serde(skip_serializing_if = "Option::is_none")]
238 pub error: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub thread_id: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub turn_id: Option<String>,
243 }
244
245 impl From<&TaskRecord> for TaskSummary {
246 fn from(value: &TaskRecord) -> Self {
247 Self {
248 id: value.id.clone(),
249 status: value.status,
250 prompt_summary: summarize_text(&value.prompt, TIMELINE_SUMMARY_LIMIT),
251 model: value.model.clone(),
252 mode: value.mode.clone(),
253 created_at: value.created_at,
254 started_at: value.started_at,
255 ended_at: value.ended_at,
256 duration_ms: value.duration_ms,
257 error: value.error.clone(),
258 thread_id: value.thread_id.clone(),
259 turn_id: value.turn_id.clone(),
260 }
261 }
262 }
263
264 /// Count totals by status for task dashboards.
265 #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
266 pub struct TaskCounts {
267 pub queued: usize,
268 pub running: usize,
269 pub completed: usize,
270 pub failed: usize,
271 pub canceled: usize,
272 }
273
274 /// Request to enqueue a new task.
275 #[derive(Debug, Clone, Serialize, Deserialize)]
276 pub struct NewTaskRequest {
277 pub prompt: String,
278 pub model: Option<String>,
279 pub workspace: Option<PathBuf>,
280 pub mode: Option<String>,
281 pub allow_shell: Option<bool>,
282 pub trust_mode: Option<bool>,
283 pub auto_approve: Option<bool>,
284 }
285
286 impl NewTaskRequest {
287 #[cfg(test)]
288 #[must_use]
289 pub fn from_prompt(prompt: impl Into<String>) -> Self {
290 Self {
291 prompt: prompt.into(),
292 model: None,
293 workspace: None,
294 mode: None,
295 allow_shell: None,
296 trust_mode: None,
297 auto_approve: Some(true),
298 }
299 }
300 }
301
302 /// Task manager startup options.
303 #[derive(Debug, Clone)]
304 pub struct TaskManagerConfig {
305 pub data_dir: PathBuf,
306 pub worker_count: usize,
307 pub default_workspace: PathBuf,
308 pub default_model: String,
309 pub default_mode: String,
310 pub allow_shell: bool,
311 pub trust_mode: bool,
312 #[allow(dead_code)]
313 pub max_subagents: usize,
314 }
315
316 impl TaskManagerConfig {
317 #[must_use]
318 pub fn from_runtime(
319 config: &Config,
320 workspace: PathBuf,
321 default_model: Option<String>,
322 worker_count: Option<usize>,
323 ) -> Self {
324 Self {
325 data_dir: default_tasks_dir(),
326 worker_count: worker_count.unwrap_or(DEFAULT_WORKERS),
327 default_workspace: workspace,
328 default_model: default_model.unwrap_or_else(|| {
329 config
330 .default_text_model
331 .clone()
332 .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string())
333 }),
334 default_mode: "agent".to_string(),
335 allow_shell: config.allow_shell(),
336 trust_mode: false,
337 max_subagents: config.max_subagents().clamp(1, MAX_SUBAGENTS),
338 }
339 }
340 }
341
342 #[derive(Debug, Clone)]
343 pub struct ExecutionTask {
344 id: String,
345 prompt: String,
346 model: String,
347 workspace: PathBuf,
348 mode_label: String,
349 allow_shell: bool,
350 trust_mode: bool,
351 auto_approve: bool,
352 }
353
354 /// Event stream produced by an executor while a task runs.
355 #[derive(Debug, Clone)]
356 pub enum TaskExecutionEvent {
357 ThreadLinked {
358 thread_id: String,
359 turn_id: String,
360 },
361 Status {
362 message: String,
363 },
364 MessageDelta {
365 content: String,
366 },
367 ToolStarted {
368 id: String,
369 name: String,
370 input: Value,
371 },
372 ToolProgress {
373 id: String,
374 output: String,
375 },
376 ToolCompleted {
377 id: String,
378 name: String,
379 success: bool,
380 output: String,
381 metadata: Option<Value>,
382 },
383 Error {
384 message: String,
385 },
386 RuntimeEvent {
387 seq: u64,
388 event: String,
389 summary: String,
390 },
391 }
392
393 /// Final executor result.
394 #[derive(Debug, Clone)]
395 pub struct TaskExecutionResult {
396 pub status: TaskStatus,
397 pub result_text: Option<String>,
398 pub error: Option<String>,
399 }
400
401 /// Abstraction for task execution.
402 #[async_trait]
403 pub trait TaskExecutor: Send + Sync {
404 async fn execute(
405 &self,
406 task: ExecutionTask,
407 events: mpsc::UnboundedSender<TaskExecutionEvent>,
408 cancel: CancellationToken,
409 ) -> TaskExecutionResult;
410 }
411
412 /// Engine-backed executor (DeepSeek-only).
413 pub struct EngineTaskExecutor {
414 runtime_threads: SharedRuntimeThreadManager,
415 }
416
417 impl EngineTaskExecutor {
418 #[must_use]
419 pub fn new(runtime_threads: SharedRuntimeThreadManager) -> Self {
420 Self { runtime_threads }
421 }
422 }
423
424 #[async_trait]
425 impl TaskExecutor for EngineTaskExecutor {
426 async fn execute(
427 &self,
428 task: ExecutionTask,
429 events: mpsc::UnboundedSender<TaskExecutionEvent>,
430 cancel: CancellationToken,
431 ) -> TaskExecutionResult {
432 let thread = match self
433 .runtime_threads
434 .create_thread(CreateThreadRequest {
435 model: Some(task.model.clone()),
436 workspace: Some(task.workspace.clone()),
437 mode: Some(task.mode_label.clone()),
438 allow_shell: Some(task.allow_shell),
439 trust_mode: Some(task.trust_mode),
440 auto_approve: Some(task.auto_approve),
441 archived: false,
442 system_prompt: None,
443 task_id: Some(task.id.clone()),
444 })
445 .await
446 {
447 Ok(thread) => thread,
448 Err(err) => {
449 return TaskExecutionResult {
450 status: TaskStatus::Failed,
451 result_text: None,
452 error: Some(format!("Failed to create runtime thread: {err}")),
453 };
454 }
455 };
456
457 let turn = match self
458 .runtime_threads
459 .start_turn(
460 &thread.id,
461 StartTurnRequest {
462 prompt: task.prompt.clone(),
463 input_summary: Some(summarize_text(&task.prompt, TIMELINE_SUMMARY_LIMIT)),
464 model: Some(task.model.clone()),
465 mode: Some(task.mode_label.clone()),
466 allow_shell: Some(task.allow_shell),
467 trust_mode: Some(task.trust_mode),
468 auto_approve: Some(task.auto_approve),
469 },
470 )
471 .await
472 {
473 Ok(turn) => turn,
474 Err(err) => {
475 return TaskExecutionResult {
476 status: TaskStatus::Failed,
477 result_text: None,
478 error: Some(format!("Failed to start task: {err}")),
479 };
480 }
481 };
482
483 let _ = events.send(TaskExecutionEvent::ThreadLinked {
484 thread_id: thread.id.clone(),
485 turn_id: turn.id.clone(),
486 });
487 let _ = events.send(TaskExecutionEvent::Status {
488 message: format!("Task {} started", task.id),
489 });
490
491 let mut final_text = String::new();
492 let mut seen_seq = 0u64;
493 let mut cancel_requested = false;
494 let mut terminal_status: Option<RuntimeTurnStatus> = None;
495 let mut terminal_error: Option<String> = None;
496
497 loop {
498 if cancel.is_cancelled() && !cancel_requested {
499 cancel_requested = true;
500 let _ = self
501 .runtime_threads
502 .interrupt_turn(&thread.id, &turn.id)
503 .await;
504 let _ = events.send(TaskExecutionEvent::Status {
505 message: "Cancellation requested".to_string(),
506 });
507 }
508
509 let batch = match self
510 .runtime_threads
511 .events_since(&thread.id, Some(seen_seq))
512 {
513 Ok(batch) => batch,
514 Err(err) => {
515 return TaskExecutionResult {
516 status: TaskStatus::Failed,
517 result_text: if final_text.trim().is_empty() {
518 None
519 } else {
520 Some(final_text)
521 },
522 error: Some(format!("Failed to read runtime events: {err}")),
523 };
524 }
525 };
526
527 for event in batch {
528 seen_seq = seen_seq.max(event.seq);
529 let _ = events.send(TaskExecutionEvent::RuntimeEvent {
530 seq: event.seq,
531 event: event.event.clone(),
532 summary: summarize_text(&event.payload.to_string(), TIMELINE_SUMMARY_LIMIT),
533 });
534
535 match event.event.as_str() {
536 "item.delta" => {
537 let kind = event
538 .payload
539 .get("kind")
540 .and_then(Value::as_str)
541 .unwrap_or_default();
542 if kind == "agent_message" {
543 if let Some(content) =
544 event.payload.get("delta").and_then(Value::as_str)
545 {
546 final_text.push_str(content);
547 let _ = events.send(TaskExecutionEvent::MessageDelta {
548 content: content.to_string(),
549 });
550 }
551 } else if kind == "tool_call" {
552 let output = event
553 .payload
554 .get("delta")
555 .and_then(Value::as_str)
556 .unwrap_or_default()
557 .to_string();
558 let _ = events.send(TaskExecutionEvent::ToolProgress {
559 id: event.item_id.clone().unwrap_or_default(),
560 output,
561 });
562 }
563 }
564 "item.started" => {
565 if let Some(tool) = event.payload.get("tool") {
566 let id = tool
567 .get("id")
568 .and_then(Value::as_str)
569 .unwrap_or_default()
570 .to_string();
571 let name = tool
572 .get("name")
573 .and_then(Value::as_str)
574 .unwrap_or_default()
575 .to_string();
576 let input = tool.get("input").cloned().unwrap_or_else(|| json!({}));
577 let _ =
578 events.send(TaskExecutionEvent::ToolStarted { id, name, input });
579 }
580 }
581 "item.completed" | "item.failed" => {
582 if let Some(item) = event.payload.get("item") {
583 let kind = item.get("kind").and_then(Value::as_str).unwrap_or_default();
584 if kind == "tool_call"
585 || kind == "file_change"
586 || kind == "command_execution"
587 {
588 let id = item
589 .get("id")
590 .and_then(Value::as_str)
591 .unwrap_or_default()
592 .to_string();
593 let name = item
594 .get("summary")
595 .and_then(Value::as_str)
596 .unwrap_or("tool")
597 .split(':')
598 .next()
599 .unwrap_or("tool")
600 .trim()
601 .to_string();
602 let output = item
603 .get("detail")
604 .and_then(Value::as_str)
605 .unwrap_or_default()
606 .to_string();
607 let metadata = item.get("metadata").cloned();
608 let _ = events.send(TaskExecutionEvent::ToolCompleted {
609 id,
610 name,
611 success: event.event == "item.completed",
612 output,
613 metadata,
614 });
615 } else if kind == "status" {
616 let message = item
617 .get("detail")
618 .and_then(Value::as_str)
619 .or_else(|| item.get("summary").and_then(Value::as_str))
620 .unwrap_or_default()
621 .to_string();
622 let _ = events.send(TaskExecutionEvent::Status { message });
623 } else if kind == "error" {
624 let message = item
625 .get("detail")
626 .and_then(Value::as_str)
627 .or_else(|| item.get("summary").and_then(Value::as_str))
628 .unwrap_or_default()
629 .to_string();
630 let _ = events.send(TaskExecutionEvent::Error { message });
631 }
632 }
633 }
634 "turn.completed" => {
635 if let Some(turn_payload) = event.payload.get("turn") {
636 let status = turn_payload
637 .get("status")
638 .and_then(Value::as_str)
639 .unwrap_or("failed");
640 terminal_status = Some(match status {
641 "completed" => RuntimeTurnStatus::Completed,
642 "interrupted" => RuntimeTurnStatus::Interrupted,
643 "canceled" => RuntimeTurnStatus::Canceled,
644 _ => RuntimeTurnStatus::Failed,
645 });
646 terminal_error = turn_payload
647 .get("error")
648 .and_then(Value::as_str)
649 .map(ToString::to_string);
650 } else {
651 terminal_status = Some(RuntimeTurnStatus::Completed);
652 }
653 }
654 _ => {}
655 }
656 }
657
658 if terminal_status.is_some() {
659 break;
660 }
661
662 sleep(Duration::from_millis(40)).await;
663 }
664
665 match terminal_status.unwrap_or(RuntimeTurnStatus::Failed) {
666 RuntimeTurnStatus::Completed => TaskExecutionResult {
667 status: TaskStatus::Completed,
668 result_text: if final_text.trim().is_empty() {
669 None
670 } else {
671 Some(final_text)
672 },
673 error: None,
674 },
675 RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => TaskExecutionResult {
676 status: TaskStatus::Canceled,
677 result_text: if final_text.trim().is_empty() {
678 None
679 } else {
680 Some(final_text)
681 },
682 error: None,
683 },
684 RuntimeTurnStatus::Queued
685 | RuntimeTurnStatus::InProgress
686 | RuntimeTurnStatus::Failed => TaskExecutionResult {
687 status: TaskStatus::Failed,
688 result_text: if final_text.trim().is_empty() {
689 None
690 } else {
691 Some(final_text)
692 },
693 error: terminal_error.or_else(|| Some("Task ended unexpectedly".to_string())),
694 },
695 }
696 }
697 }
698
699 /// Thread-safe task manager.
700 pub type SharedTaskManager = Arc<TaskManager>;
701
702 pub struct TaskManager {
703 cfg: TaskManagerConfig,
704 executor: Arc<dyn TaskExecutor>,
705 tasks_dir: PathBuf,
706 artifacts_dir: PathBuf,
707 queue_path: PathBuf,
708 state: Mutex<ManagerState>,
709 notify: Notify,
710 cancel_token: CancellationToken,
711 }
712
713 struct ManagerState {
714 tasks: HashMap<String, TaskRecord>,
715 queue: VecDeque<String>,
716 running_cancel: HashMap<String, CancellationToken>,
717 }
718
719 #[derive(Debug, Serialize, Deserialize, Default)]
720 struct QueueFile {
721 queue: Vec<String>,
722 }
723
724 impl TaskManager {
725 /// Start the manager with the default DeepSeek executor.
726 pub async fn start(cfg: TaskManagerConfig, api_config: Config) -> Result<SharedTaskManager> {
727 let runtime_threads = Arc::new(RuntimeThreadManager::open(
728 api_config.clone(),
729 cfg.default_workspace.clone(),
730 RuntimeThreadManagerConfig::from_task_data_dir(cfg.data_dir.clone()),
731 )?);
732 Self::start_with_runtime_manager(cfg, api_config, runtime_threads).await
733 }
734
735 /// Start the manager with an injected runtime thread manager.
736 pub async fn start_with_runtime_manager(
737 cfg: TaskManagerConfig,
738 _api_config: Config,
739 runtime_threads: SharedRuntimeThreadManager,
740 ) -> Result<SharedTaskManager> {
741 let executor: Arc<dyn TaskExecutor> =
742 Arc::new(EngineTaskExecutor::new(runtime_threads.clone()));
743 let manager = Self::start_with_executor(cfg, executor).await?;
744 runtime_threads.attach_task_manager(manager.clone());
745 Ok(manager)
746 }
747
748 /// Start the manager with a custom executor (used for tests).
749 pub async fn start_with_executor(
750 cfg: TaskManagerConfig,
751 executor: Arc<dyn TaskExecutor>,
752 ) -> Result<SharedTaskManager> {
753 let workers = cfg.worker_count.clamp(1, MAX_WORKERS);
754 let tasks_dir = cfg.data_dir.join("tasks");
755 let artifacts_dir = cfg.data_dir.join("artifacts");
756 let queue_path = cfg.data_dir.join("queue.json");
757 fs::create_dir_all(&tasks_dir)
758 .with_context(|| format!("Failed to create tasks dir {}", tasks_dir.display()))?;
759 fs::create_dir_all(&artifacts_dir).with_context(|| {
760 format!(
761 "Failed to create task artifacts dir {}",
762 artifacts_dir.display()
763 )
764 })?;
765
766 let (tasks, queue) = load_state(&tasks_dir, &queue_path)?;
767
768 let cancel_token = CancellationToken::new();
769 let manager = Arc::new(Self {
770 cfg,
771 executor,
772 tasks_dir,
773 artifacts_dir,
774 queue_path,
775 state: Mutex::new(ManagerState {
776 tasks,
777 queue,
778 running_cancel: HashMap::new(),
779 }),
780 notify: Notify::new(),
781 cancel_token: cancel_token.clone(),
782 });
783
784 {
785 let state = manager.state.lock().await;
786 manager.persist_all_locked(&state)?;
787 }
788
789 for _ in 0..workers {
790 let manager_clone = Arc::clone(&manager);
791 spawn_supervised(
792 "task-manager-worker",
793 std::panic::Location::caller(),
794 async move {
795 manager_clone.worker_loop().await;
796 },
797 );
798 }
799
800 Ok(manager)
801 }
802
803 #[allow(dead_code)] // Public API for external callers (runtime API)
804 pub fn shutdown(&self) {
805 self.cancel_token.cancel();
806 }
807
808 #[allow(dead_code)] // Public API for external callers
809 pub fn is_shutdown(&self) -> bool {
810 self.cancel_token.is_cancelled()
811 }
812
813 /// Enqueue a new task.
814 pub async fn add_task(&self, req: NewTaskRequest) -> Result<TaskRecord> {
815 let prompt = req.prompt.trim().to_string();
816 if prompt.is_empty() {
817 bail!("Task prompt cannot be empty");
818 }
819
820 let task = TaskRecord {
821 schema_version: CURRENT_TASK_SCHEMA_VERSION,
822 id: format!("task_{}", &Uuid::new_v4().to_string()[..8]),
823 prompt,
824 model: req.model.unwrap_or_else(|| self.cfg.default_model.clone()),
825 workspace: req
826 .workspace
827 .unwrap_or_else(|| self.cfg.default_workspace.clone()),
828 mode: req.mode.unwrap_or_else(|| self.cfg.default_mode.clone()),
829 allow_shell: req.allow_shell.unwrap_or(self.cfg.allow_shell),
830 trust_mode: req.trust_mode.unwrap_or(self.cfg.trust_mode),
831 auto_approve: req.auto_approve.unwrap_or(true),
832 status: TaskStatus::Queued,
833 created_at: Utc::now(),
834 started_at: None,
835 ended_at: None,
836 duration_ms: None,
837 result_summary: None,
838 result_detail_path: None,
839 error: None,
840 thread_id: None,
841 turn_id: None,
842 runtime_event_count: 0,
843 checklist: TaskChecklistState::default(),
844 gates: Vec::new(),
845 attempts: Vec::new(),
846 artifacts: Vec::new(),
847 github_events: Vec::new(),
848 tool_calls: Vec::new(),
849 timeline: vec![TaskTimelineEntry {
850 timestamp: Utc::now(),
851 kind: "queued".to_string(),
852 summary: "Task queued".to_string(),
853 detail_path: None,
854 }],
855 };
856
857 {
858 let mut state = self.state.lock().await;
859 state.queue.push_back(task.id.clone());
860 state.tasks.insert(task.id.clone(), task.clone());
861 self.persist_all_locked(&state)?;
862 }
863 self.notify.notify_one();
864 Ok(task)
865 }
866
867 /// List tasks, newest first.
868 pub async fn list_tasks(&self, limit: Option<usize>) -> Vec<TaskSummary> {
869 let state = self.state.lock().await;
870 let mut items = state
871 .tasks
872 .values()
873 .map(TaskSummary::from)
874 .collect::<Vec<_>>();
875 items.sort_by_key(|i| std::cmp::Reverse(i.created_at));
876 if let Some(limit) = limit {
877 items.truncate(limit);
878 }
879 items
880 }
881
882 /// Retrieve a task by full id or prefix.
883 pub async fn get_task(&self, id_or_prefix: &str) -> Result<TaskRecord> {
884 let state = self.state.lock().await;
885 let id = resolve_task_id(&state.tasks, id_or_prefix)?;
886 state
887 .tasks
888 .get(&id)
889 .cloned()
890 .ok_or_else(|| anyhow!("Task not found: {id_or_prefix}"))
891 }
892
893 /// Cancel a queued or running task by id/prefix.
894 pub async fn cancel_task(&self, id_or_prefix: &str) -> Result<TaskRecord> {
895 let mut state = self.state.lock().await;
896 let id = resolve_task_id(&state.tasks, id_or_prefix)?;
897 let now = Utc::now();
898
899 let mut cancel_running = false;
900 {
901 let task = state
902 .tasks
903 .get_mut(&id)
904 .ok_or_else(|| anyhow!("Task not found: {id}"))?;
905 match task.status {
906 TaskStatus::Queued => {
907 task.status = TaskStatus::Canceled;
908 task.ended_at = Some(now);
909 task.duration_ms = Some(0);
910 task.timeline.push(TaskTimelineEntry {
911 timestamp: now,
912 kind: "canceled".to_string(),
913 summary: "Task canceled before execution".to_string(),
914 detail_path: None,
915 });
916 state.queue.retain(|queued_id| queued_id != &id);
917 }
918 TaskStatus::Running => {
919 cancel_running = true;
920 task.timeline.push(TaskTimelineEntry {
921 timestamp: now,
922 kind: "cancel_requested".to_string(),
923 summary: "Cancellation requested".to_string(),
924 detail_path: None,
925 });
926 }
927 _ => {}
928 }
929 }
930
931 if cancel_running && let Some(token) = state.running_cancel.get(&id) {
932 token.cancel();
933 }
934
935 self.persist_all_locked(&state)?;
936 state
937 .tasks
938 .get(&id)
939 .cloned()
940 .ok_or_else(|| anyhow!("Task not found: {id}"))
941 }
942
943 /// Return aggregate status counters.
944 pub async fn counts(&self) -> TaskCounts {
945 let state = self.state.lock().await;
946 let mut counts = TaskCounts::default();
947 for task in state.tasks.values() {
948 match task.status {
949 TaskStatus::Queued => counts.queued += 1,
950 TaskStatus::Running => counts.running += 1,
951 TaskStatus::Completed => counts.completed += 1,
952 TaskStatus::Failed => counts.failed += 1,
953 TaskStatus::Canceled => counts.canceled += 1,
954 }
955 }
956 counts
957 }
958
959 /// Root directory for durable task state.
960 #[must_use]
961 pub fn data_dir(&self) -> PathBuf {
962 self.cfg.data_dir.clone()
963 }
964
965 /// Resolve a task artifact reference to an absolute path.
966 #[must_use]
967 pub fn artifact_absolute_path(&self, path: &Path) -> PathBuf {
968 if path.is_absolute() {
969 path.to_path_buf()
970 } else {
971 self.cfg.data_dir.join(path)
972 }
973 }
974
975 /// Write a durable task artifact and return the persisted path reference.
976 pub fn write_task_artifact(
977 &self,
978 task_id: &str,
979 label: &str,
980 content: &str,
981 ) -> Result<PathBuf> {
982 self.write_artifact(task_id, label, content)
983 }
984
985 /// Apply model-visible tool metadata to a task and persist it.
986 pub async fn record_tool_metadata(
987 &self,
988 id_or_prefix: &str,
989 metadata: &Value,
990 ) -> Result<TaskRecord> {
991 let mut state = self.state.lock().await;
992 let id = resolve_task_id(&state.tasks, id_or_prefix)?;
993 let updated = {
994 let task = state
995 .tasks
996 .get_mut(&id)
997 .ok_or_else(|| anyhow!("Task not found: {id}"))?;
998 self.apply_task_update_metadata(task, Some(metadata))?;
999 task.clone()
1000 };
1001 self.persist_task_locked(&updated)?;
1002 Ok(updated)
1003 }
1004
1005 async fn worker_loop(self: Arc<Self>) {
1006 loop {
1007 if self.cancel_token.is_cancelled() {
1008 tracing::debug!("Worker exiting due to shutdown");
1009 break;
1010 }
1011 let next = {
1012 let mut state = self.state.lock().await;
1013 match state.queue.pop_front() {
1014 None => None,
1015 Some(task_id) => {
1016 if let Some(task) = state.tasks.get_mut(&task_id) {
1017 if task.status != TaskStatus::Queued {
1018 let _ = self.persist_queue_locked(&state.queue);
1019 None
1020 } else {
1021 let now = Utc::now();
1022 task.status = TaskStatus::Running;
1023 task.started_at = Some(now);
1024 task.ended_at = None;
1025 task.duration_ms = None;
1026 task.error = None;
1027 task.timeline.push(TaskTimelineEntry {
1028 timestamp: now,
1029 kind: "running".to_string(),
1030 summary: "Task started".to_string(),
1031 detail_path: None,
1032 });
1033
1034 let request = {
1035 ExecutionTask {
1036 id: task.id.clone(),
1037 prompt: task.prompt.clone(),
1038 model: task.model.clone(),
1039 workspace: task.workspace.clone(),
1040 mode_label: task.mode.clone(),
1041 allow_shell: task.allow_shell,
1042 trust_mode: task.trust_mode,
1043 auto_approve: task.auto_approve,
1044 }
1045 };
1046 let cancel = CancellationToken::new();
1047 state.running_cancel.insert(task_id.clone(), cancel.clone());
1048
1049 if let Err(err) = self.persist_all_locked(&state) {
1050 tracing::error!("Failed to persist task start: {err}");
1051 }
1052 Some((task_id, request, cancel))
1053 }
1054 } else {
1055 let _ = self.persist_queue_locked(&state.queue);
1056 None
1057 }
1058 }
1059 }
1060 };
1061
1062 let Some((task_id, request, cancel)) = next else {
1063 tokio::select! {
1064 _ = self.cancel_token.cancelled() => {
1065 tracing::debug!("Worker exiting during wait");
1066 break;
1067 }
1068 _ = self.notify.notified() => {}
1069 }
1070 continue;
1071 };
1072
1073 self.run_task(task_id, request, cancel).await;
1074 }
1075 }
1076
1077 async fn run_task(&self, task_id: String, request: ExecutionTask, cancel: CancellationToken) {
1078 let (event_tx, mut event_rx) = mpsc::unbounded_channel();
1079 let exec_fut = self
1080 .executor
1081 .execute(request.clone(), event_tx, cancel.clone());
1082 tokio::pin!(exec_fut);
1083
1084 let result = loop {
1085 tokio::select! {
1086 maybe_event = event_rx.recv() => {
1087 if let Some(event) = maybe_event
1088 && let Err(err) = self.apply_execution_event(&task_id, event).await
1089 {
1090 tracing::error!("Failed to apply task event for {task_id}: {err}");
1091 }
1092 }
1093 exec_result = &mut exec_fut => {
1094 break exec_result;
1095 }
1096 }
1097 };
1098
1099 while let Ok(event) = event_rx.try_recv() {
1100 if let Err(err) = self.apply_execution_event(&task_id, event).await {
1101 tracing::error!("Failed to apply trailing task event for {task_id}: {err}");
1102 }
1103 }
1104
1105 if let Err(err) = self
1106 .finish_task(&task_id, result, cancel, &request.mode_label)
1107 .await
1108 {
1109 tracing::error!("Failed to finalize task {task_id}: {err}");
1110 }
1111 }
1112
1113 async fn apply_execution_event(&self, task_id: &str, event: TaskExecutionEvent) -> Result<()> {
1114 let mut state = self.state.lock().await;
1115 let Some(task) = state.tasks.get_mut(task_id) else {
1116 return Ok(());
1117 };
1118
1119 match event {
1120 TaskExecutionEvent::ThreadLinked { thread_id, turn_id } => {
1121 task.thread_id = Some(thread_id.clone());
1122 task.turn_id = Some(turn_id.clone());
1123 task.timeline.push(TaskTimelineEntry {
1124 timestamp: Utc::now(),
1125 kind: "runtime_link".to_string(),
1126 summary: format!("Linked runtime thread {thread_id} turn {turn_id}"),
1127 detail_path: None,
1128 });
1129 }
1130 TaskExecutionEvent::Status { message } => {
1131 task.timeline.push(TaskTimelineEntry {
1132 timestamp: Utc::now(),
1133 kind: "status".to_string(),
1134 summary: summarize_text(&message, TIMELINE_SUMMARY_LIMIT),
1135 detail_path: None,
1136 });
1137 }
1138 TaskExecutionEvent::MessageDelta { content } => {
1139 if !content.trim().is_empty() {
1140 task.timeline.push(TaskTimelineEntry {
1141 timestamp: Utc::now(),
1142 kind: "message".to_string(),
1143 summary: summarize_text(&content, TIMELINE_SUMMARY_LIMIT),
1144 detail_path: None,
1145 });
1146 }
1147 }
1148 TaskExecutionEvent::ToolStarted { id, name, input } => {
1149 let input_summary = summarize_json(&input);
1150 task.tool_calls.push(TaskToolCallSummary {
1151 id: id.clone(),
1152 name: name.clone(),
1153 status: TaskToolStatus::Running,
1154 started_at: Utc::now(),
1155 ended_at: None,
1156 duration_ms: None,
1157 input_summary: input_summary.clone(),
1158 output_summary: None,
1159 detail_path: None,
1160 patch_ref: None,
1161 });
1162 let summary = input_summary
1163 .map(|s| format!("{name} started ({s})"))
1164 .unwrap_or_else(|| format!("{name} started"));
1165 task.timeline.push(TaskTimelineEntry {
1166 timestamp: Utc::now(),
1167 kind: "tool_started".to_string(),
1168 summary,
1169 detail_path: None,
1170 });
1171 }
1172 TaskExecutionEvent::ToolProgress { id, output } => {
1173 task.timeline.push(TaskTimelineEntry {
1174 timestamp: Utc::now(),
1175 kind: "tool_progress".to_string(),
1176 summary: format!(
1177 "{id}: {}",
1178 summarize_text(&output, TIMELINE_SUMMARY_LIMIT.saturating_sub(8))
1179 ),
1180 detail_path: None,
1181 });
1182 }
1183 TaskExecutionEvent::ToolCompleted {
1184 id,
1185 name,
1186 success,
1187 output,
1188 metadata,
1189 } => {
1190 let now = Utc::now();
1191 let detail_path = self.artifact_if_large(task_id, &name, &output)?;
1192 let output_summary = summarize_text(&output, TIMELINE_SUMMARY_LIMIT);
1193 let patch_ref = if name == "apply_patch" {
1194 detail_path.clone()
1195 } else {
1196 None
1197 };
1198
1199 if let Some(call) = task.tool_calls.iter_mut().find(|call| call.id == id) {
1200 call.status = if success {
1201 TaskToolStatus::Success
1202 } else {
1203 TaskToolStatus::Failed
1204 };
1205 call.ended_at = Some(now);
1206 call.duration_ms = Some(duration_ms(call.started_at, now));
1207 call.output_summary = Some(output_summary.clone());
1208 call.detail_path = detail_path.clone();
1209 call.patch_ref = patch_ref.clone();
1210
1211 if call.duration_ms.is_none()
1212 && let Some(duration) = metadata
1213 .as_ref()
1214 .and_then(|m| m.get("duration_ms"))
1215 .and_then(Value::as_u64)
1216 {
1217 call.duration_ms = Some(duration);
1218 }
1219 }
1220
1221 let status = if success { "success" } else { "failed" };
1222 task.timeline.push(TaskTimelineEntry {
1223 timestamp: now,
1224 kind: "tool_completed".to_string(),
1225 summary: format!("{name} {status}: {output_summary}"),
1226 detail_path: detail_path.clone(),
1227 });
1228 if let Some(patch_ref) = patch_ref {
1229 task.timeline.push(TaskTimelineEntry {
1230 timestamp: now,
1231 kind: "patch_ref".to_string(),
1232 summary: format!("Patch artifact: {}", patch_ref.display()),
1233 detail_path: Some(patch_ref),
1234 });
1235 }
1236
1237 self.apply_task_update_metadata(task, metadata.as_ref())?;
1238 }
1239 TaskExecutionEvent::Error { message } => {
1240 task.timeline.push(TaskTimelineEntry {
1241 timestamp: Utc::now(),
1242 kind: "error".to_string(),
1243 summary: summarize_text(&message, TIMELINE_SUMMARY_LIMIT),
1244 detail_path: None,
1245 });
1246 }
1247 TaskExecutionEvent::RuntimeEvent {
1248 seq,
1249 event,
1250 summary,
1251 } => {
1252 task.runtime_event_count = task.runtime_event_count.saturating_add(1);
1253 task.timeline.push(TaskTimelineEntry {
1254 timestamp: Utc::now(),
1255 kind: "runtime_event".to_string(),
1256 summary: format!("#{seq} {event}: {summary}"),
1257 detail_path: None,
1258 });
1259 }
1260 }
1261
1262 self.persist_task_locked(task)?;
1263 Ok(())
1264 }
1265
1266 async fn finish_task(
1267 &self,
1268 task_id: &str,
1269 mut result: TaskExecutionResult,
1270 cancel: CancellationToken,
1271 mode_label: &str,
1272 ) -> Result<()> {
1273 let mut state = self.state.lock().await;
1274 state.running_cancel.remove(task_id);
1275 let Some(task) = state.tasks.get_mut(task_id) else {
1276 return Ok(());
1277 };
1278
1279 let now = Utc::now();
1280 if cancel.is_cancelled() && result.status == TaskStatus::Completed {
1281 result.status = TaskStatus::Canceled;
1282 result.result_text = None;
1283 result.error = None;
1284 }
1285
1286 task.status = result.status;
1287 task.mode = mode_label.to_string();
1288 task.ended_at = Some(now);
1289 task.duration_ms = task.started_at.map(|start| duration_ms(start, now));
1290 task.error = result.error.clone();
1291 task.timeline.push(TaskTimelineEntry {
1292 timestamp: now,
1293 kind: "finished".to_string(),
1294 summary: match result.status {
1295 TaskStatus::Completed => "Task completed".to_string(),
1296 TaskStatus::Failed => format!(
1297 "Task failed: {}",
1298 result
1299 .error
1300 .as_deref()
1301 .map(|e| summarize_text(e, TIMELINE_SUMMARY_LIMIT))
1302 .unwrap_or_else(|| "unknown error".to_string())
1303 ),
1304 TaskStatus::Canceled => "Task canceled".to_string(),
1305 TaskStatus::Queued | TaskStatus::Running => {
1306 format!("Task ended in unexpected state: {}", mode_label)
1307 }
1308 },
1309 detail_path: None,
1310 });
1311
1312 if let Some(text) = result.result_text {
1313 let detail_path = self.artifact_if_large(task_id, "result", &text)?;
1314 task.result_summary = Some(summarize_text(&text, TIMELINE_SUMMARY_LIMIT));
1315 task.result_detail_path = detail_path.clone();
1316 if let Some(detail_path) = detail_path {
1317 task.timeline.push(TaskTimelineEntry {
1318 timestamp: now,
1319 kind: "result_ref".to_string(),
1320 summary: format!("Result artifact: {}", detail_path.display()),
1321 detail_path: Some(detail_path),
1322 });
1323 }
1324 } else if result.status == TaskStatus::Completed {
1325 task.result_summary = Some("(no textual output)".to_string());
1326 }
1327
1328 self.persist_all_locked(&state)?;
1329 Ok(())
1330 }
1331
1332 fn artifact_if_large(
1333 &self,
1334 task_id: &str,
1335 label: &str,
1336 content: &str,
1337 ) -> Result<Option<PathBuf>> {
1338 if content.len() < ARTIFACT_THRESHOLD {
1339 return Ok(None);
1340 }
1341 self.write_artifact(task_id, label, content).map(Some)
1342 }
1343
1344 fn write_artifact(&self, task_id: &str, label: &str, content: &str) -> Result<PathBuf> {
1345 let artifact_dir = self.artifacts_dir.join(task_id);
1346 fs::create_dir_all(&artifact_dir)
1347 .with_context(|| format!("Failed to create artifact dir {}", artifact_dir.display()))?;
1348 let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1349 let filename = format!("{stamp}_{}.txt", sanitize_filename(label));
1350 let absolute = artifact_dir.join(filename);
1351 fs::write(&absolute, content)
1352 .with_context(|| format!("Failed to write artifact {}", absolute.display()))?;
1353 let relative = absolute
1354 .strip_prefix(&self.cfg.data_dir)
1355 .map(PathBuf::from)
1356 .unwrap_or(absolute);
1357 Ok(relative)
1358 }
1359
1360 fn apply_task_update_metadata(
1361 &self,
1362 task: &mut TaskRecord,
1363 metadata: Option<&Value>,
1364 ) -> Result<()> {
1365 let Some(updates) = metadata.and_then(|m| m.get("task_updates")) else {
1366 return Ok(());
1367 };
1368 let now = Utc::now();
1369
1370 if let Some(value) = updates.get("checklist") {
1371 let mut checklist: TaskChecklistState = serde_json::from_value(value.clone())
1372 .context("Failed to parse checklist task update")?;
1373 checklist.updated_at = checklist.updated_at.or(Some(now));
1374 task.checklist = checklist;
1375 task.timeline.push(TaskTimelineEntry {
1376 timestamp: now,
1377 kind: "checklist".to_string(),
1378 summary: format!(
1379 "Checklist updated: {} item(s), {}% complete",
1380 task.checklist.items.len(),
1381 task.checklist.completion_pct
1382 ),
1383 detail_path: None,
1384 });
1385 }
1386
1387 if let Some(value) = updates.get("gate") {
1388 let gate: TaskGateRecord = serde_json::from_value(value.clone())
1389 .context("Failed to parse gate task update")?;
1390 let summary = format!("Gate {} {}: {}", gate.gate, gate.status, gate.summary);
1391 task.gates.retain(|existing| existing.id != gate.id);
1392 task.gates.push(gate.clone());
1393 task.timeline.push(TaskTimelineEntry {
1394 timestamp: now,
1395 kind: "gate".to_string(),
1396 summary: summarize_text(&summary, TIMELINE_SUMMARY_LIMIT),
1397 detail_path: gate.log_path,
1398 });
1399 }
1400
1401 if let Some(value) = updates.get("attempt") {
1402 let attempt: TaskAttemptRecord = serde_json::from_value(value.clone())
1403 .context("Failed to parse attempt task update")?;
1404 task.attempts.retain(|existing| existing.id != attempt.id);
1405 task.attempts.push(attempt.clone());
1406 task.timeline.push(TaskTimelineEntry {
1407 timestamp: now,
1408 kind: "pr_attempt".to_string(),
1409 summary: format!(
1410 "Attempt {}/{} recorded for {}",
1411 attempt.attempt_index, attempt.attempt_count, attempt.attempt_group_id
1412 ),
1413 detail_path: attempt.patch_path,
1414 });
1415 }
1416
1417 if let Some(value) = updates.get("artifacts")
1418 && let Some(items) = value.as_array()
1419 {
1420 for item in items {
1421 let artifact: TaskArtifactRef = serde_json::from_value(item.clone())
1422 .context("Failed to parse artifact task update")?;
1423 task.timeline.push(TaskTimelineEntry {
1424 timestamp: now,
1425 kind: "artifact".to_string(),
1426 summary: format!("{}: {}", artifact.label, artifact.summary),
1427 detail_path: Some(artifact.path.clone()),
1428 });
1429 task.artifacts.push(artifact);
1430 }
1431 }
1432
1433 if let Some(value) = updates.get("github_event") {
1434 let event: TaskGithubEvent = serde_json::from_value(value.clone())
1435 .context("Failed to parse GitHub task update")?;
1436 task.timeline.push(TaskTimelineEntry {
1437 timestamp: now,
1438 kind: "github".to_string(),
1439 summary: format!(
1440 "{} {}#{}: {}",
1441 event.action, event.target, event.number, event.summary
1442 ),
1443 detail_path: None,
1444 });
1445 task.github_events.push(event);
1446 }
1447
1448 Ok(())
1449 }
1450
1451 fn persist_all_locked(&self, state: &ManagerState) -> Result<()> {
1452 self.persist_queue_locked(&state.queue)?;
1453 for task in state.tasks.values() {
1454 self.persist_task_locked(task)?;
1455 }
1456 Ok(())
1457 }
1458
1459 fn persist_queue_locked(&self, queue: &VecDeque<String>) -> Result<()> {
1460 write_json_atomic(
1461 &self.queue_path,
1462 &QueueFile {
1463 queue: queue.iter().cloned().collect(),
1464 },
1465 )
1466 }
1467
1468 fn persist_task_locked(&self, task: &TaskRecord) -> Result<()> {
1469 let path = self.tasks_dir.join(format!("{}.json", task.id));
1470 write_json_atomic(&path, task)
1471 }
1472 }
1473
1474 fn load_state(
1475 tasks_dir: &Path,
1476 queue_path: &Path,
1477 ) -> Result<(HashMap<String, TaskRecord>, VecDeque<String>)> {
1478 let mut tasks = HashMap::new();
1479 if tasks_dir.exists() {
1480 for entry in fs::read_dir(tasks_dir)
1481 .with_context(|| format!("Failed to read tasks dir {}", tasks_dir.display()))?
1482 {
1483 let entry = entry?;
1484 let path = entry.path();
1485 if path.extension().is_none_or(|ext| ext != "json") {
1486 continue;
1487 }
1488 let content = fs::read_to_string(&path)
1489 .with_context(|| format!("Failed to read task file {}", path.display()))?;
1490 let mut task: TaskRecord = serde_json::from_str(&content)
1491 .with_context(|| format!("Failed to parse task file {}", path.display()))?;
1492 if task.schema_version > CURRENT_TASK_SCHEMA_VERSION {
1493 bail!(
1494 "Task schema v{} is newer than supported v{}",
1495 task.schema_version,
1496 CURRENT_TASK_SCHEMA_VERSION
1497 );
1498 }
1499 if task.status == TaskStatus::Running {
1500 task.status = TaskStatus::Queued;
1501 task.started_at = None;
1502 task.ended_at = None;
1503 task.duration_ms = None;
1504 task.timeline.push(TaskTimelineEntry {
1505 timestamp: Utc::now(),
1506 kind: "recovered".to_string(),
1507 summary: "Recovered from restart and re-queued".to_string(),
1508 detail_path: None,
1509 });
1510 }
1511 tasks.insert(task.id.clone(), task);
1512 }
1513 }
1514
1515 let mut queue = if queue_path.exists() {
1516 let content = fs::read_to_string(queue_path)
1517 .with_context(|| format!("Failed to read queue file {}", queue_path.display()))?;
1518 let parsed: QueueFile = serde_json::from_str(&content)
1519 .with_context(|| format!("Failed to parse queue file {}", queue_path.display()))?;
1520 VecDeque::from(parsed.queue)
1521 } else {
1522 VecDeque::new()
1523 };
1524
1525 queue.retain(|id| {
1526 tasks
1527 .get(id)
1528 .is_some_and(|task| task.status == TaskStatus::Queued)
1529 });
1530
1531 let known = queue.iter().cloned().collect::<HashSet<_>>();
1532 let mut missing = tasks
1533 .values()
1534 .filter(|task| task.status == TaskStatus::Queued && !known.contains(&task.id))
1535 .map(|task| task.id.clone())
1536 .collect::<Vec<_>>();
1537 missing.sort();
1538 for id in missing {
1539 queue.push_back(id);
1540 }
1541
1542 Ok((tasks, queue))
1543 }
1544
1545 fn resolve_task_id(tasks: &HashMap<String, TaskRecord>, id_or_prefix: &str) -> Result<String> {
1546 if tasks.contains_key(id_or_prefix) {
1547 return Ok(id_or_prefix.to_string());
1548 }
1549 let matches = tasks
1550 .keys()
1551 .filter(|id| id.starts_with(id_or_prefix))
1552 .cloned()
1553 .collect::<Vec<_>>();
1554 match matches.len() {
1555 0 => bail!("Task not found: {id_or_prefix}"),
1556 1 => Ok(matches[0].clone()),
1557 _ => bail!(
1558 "Ambiguous task prefix '{}': matches {} tasks",
1559 id_or_prefix,
1560 matches.len()
1561 ),
1562 }
1563 }
1564
1565 fn summarize_json(value: &Value) -> Option<String> {
1566 let text = serde_json::to_string(value).ok()?;
1567 Some(summarize_text(&text, TIMELINE_SUMMARY_LIMIT))
1568 }
1569
1570 fn summarize_text(text: &str, limit: usize) -> String {
1571 let take = limit.saturating_sub(3);
1572 let mut count = 0;
1573 let mut out = String::new();
1574 for ch in text.chars() {
1575 if count >= take {
1576 out.push_str("...");
1577 return out;
1578 }
1579 if ch.is_control() && ch != '\n' && ch != '\t' {
1580 continue;
1581 }
1582 out.push(ch);
1583 count += 1;
1584 }
1585 out
1586 }
1587
1588 fn sanitize_filename(input: &str) -> String {
1589 let mut out = String::new();
1590 for ch in input.chars() {
1591 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
1592 out.push(ch);
1593 } else {
1594 out.push('_');
1595 }
1596 }
1597 if out.is_empty() {
1598 "artifact".to_string()
1599 } else {
1600 out
1601 }
1602 }
1603
1604 fn duration_ms(start: DateTime<Utc>, end: DateTime<Utc>) -> u64 {
1605 let millis = (end - start).num_milliseconds();
1606 if millis.is_negative() {
1607 0
1608 } else {
1609 u64::try_from(millis).unwrap_or(u64::MAX)
1610 }
1611 }
1612
1613 fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
1614 if let Some(parent) = path.parent() {
1615 fs::create_dir_all(parent)
1616 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
1617 }
1618 let payload = serde_json::to_string_pretty(value)?;
1619 crate::utils::write_atomic(path, payload.as_bytes())
1620 .with_context(|| format!("Failed to write {}", path.display()))
1621 }
1622
1623 fn default_auto_approve() -> bool {
1624 true
1625 }
1626
1627 /// Default task persistence location (`~/.deepseek/tasks`).
1628 #[must_use]
1629 pub fn default_tasks_dir() -> PathBuf {
1630 if let Ok(path) = std::env::var("DEEPSEEK_TASKS_DIR")
1631 && !path.trim().is_empty()
1632 {
1633 return PathBuf::from(path);
1634 }
1635 if let Some(home) = dirs::home_dir() {
1636 return home.join(".deepseek").join("tasks");
1637 }
1638 PathBuf::from(".deepseek").join("tasks")
1639 }
1640
1641 /// Wait for a task to reach a terminal status (tests and API helpers).
1642 #[cfg(test)]
1643 pub async fn wait_for_terminal_state(
1644 manager: &TaskManager,
1645 task_id: &str,
1646 timeout: StdDuration,
1647 ) -> Result<TaskRecord> {
1648 let deadline = std::time::Instant::now() + timeout;
1649 loop {
1650 let task = manager.get_task(task_id).await?;
1651 if task.status.is_terminal() {
1652 return Ok(task);
1653 }
1654 if std::time::Instant::now() >= deadline {
1655 bail!("Timed out waiting for task {task_id}");
1656 }
1657 sleep(StdDuration::from_millis(50)).await;
1658 }
1659 }
1660
1661 #[cfg(test)]
1662 mod tests {
1663 use super::*;
1664 use std::fs;
1665 use tokio::time::Duration;
1666
1667 struct MockExecutor;
1668
1669 #[async_trait]
1670 impl TaskExecutor for MockExecutor {
1671 async fn execute(
1672 &self,
1673 task: ExecutionTask,
1674 events: mpsc::UnboundedSender<TaskExecutionEvent>,
1675 cancel: CancellationToken,
1676 ) -> TaskExecutionResult {
1677 let _ = events.send(TaskExecutionEvent::Status {
1678 message: format!("running {}", task.id),
1679 });
1680 let _ = events.send(TaskExecutionEvent::ThreadLinked {
1681 thread_id: "thr_test".to_string(),
1682 turn_id: "turn_test".to_string(),
1683 });
1684 let _ = events.send(TaskExecutionEvent::ToolStarted {
1685 id: "tool_1".to_string(),
1686 name: "read_file".to_string(),
1687 input: serde_json::json!({ "path": "README.md" }),
1688 });
1689 sleep(Duration::from_millis(50)).await;
1690 if cancel.is_cancelled() {
1691 return TaskExecutionResult {
1692 status: TaskStatus::Canceled,
1693 result_text: None,
1694 error: None,
1695 };
1696 }
1697 let _ = events.send(TaskExecutionEvent::ToolCompleted {
1698 id: "tool_1".to_string(),
1699 name: "read_file".to_string(),
1700 success: true,
1701 output: "read ok".to_string(),
1702 metadata: Some(serde_json::json!({
1703 "duration_ms": 10,
1704 "task_updates": {
1705 "checklist": {
1706 "items": [
1707 { "id": 1, "content": "read fixture", "status": "in_progress" }
1708 ],
1709 "completion_pct": 0,
1710 "in_progress_id": 1,
1711 "updated_at": null
1712 }
1713 }
1714 })),
1715 });
1716 TaskExecutionResult {
1717 status: TaskStatus::Completed,
1718 result_text: Some("done".to_string()),
1719 error: None,
1720 }
1721 }
1722 }
1723
1724 fn test_config(root: PathBuf) -> TaskManagerConfig {
1725 TaskManagerConfig {
1726 data_dir: root,
1727 worker_count: 1,
1728 default_workspace: PathBuf::from("."),
1729 default_model: "deepseek-v4-flash".to_string(),
1730 default_mode: "agent".to_string(),
1731 allow_shell: false,
1732 trust_mode: false,
1733 max_subagents: 2,
1734 }
1735 }
1736
1737 #[tokio::test]
1738 async fn persists_and_recovers_task_records() -> Result<()> {
1739 let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
1740 let manager =
1741 TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
1742 .await?;
1743
1744 let task = manager
1745 .add_task(NewTaskRequest::from_prompt("test persistence"))
1746 .await?;
1747 let finished = wait_for_terminal_state(&manager, &task.id, Duration::from_secs(3)).await?;
1748 assert_eq!(finished.status, TaskStatus::Completed);
1749 assert_eq!(finished.thread_id.as_deref(), Some("thr_test"));
1750 assert_eq!(finished.turn_id.as_deref(), Some("turn_test"));
1751 assert_eq!(finished.checklist.items.len(), 1);
1752 assert_eq!(finished.checklist.in_progress_id, Some(1));
1753
1754 drop(manager);
1755
1756 let recovered =
1757 TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
1758 .await?;
1759 let loaded = recovered.get_task(&task.id).await?;
1760 assert_eq!(loaded.status, TaskStatus::Completed);
1761 assert!(!loaded.timeline.is_empty());
1762 assert_eq!(loaded.checklist.items[0].content, "read fixture");
1763 Ok(())
1764 }
1765
1766 #[tokio::test]
1767 async fn record_tool_metadata_updates_explicit_task() -> Result<()> {
1768 let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
1769 let manager =
1770 TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await?;
1771
1772 let task = manager
1773 .add_task(NewTaskRequest::from_prompt("test metadata"))
1774 .await?;
1775 let finished = wait_for_terminal_state(&manager, &task.id, Duration::from_secs(3)).await?;
1776 let updated = manager
1777 .record_tool_metadata(
1778 &finished.id,
1779 &serde_json::json!({
1780 "task_updates": {
1781 "gate": {
1782 "id": "gate_test",
1783 "gate": "test",
1784 "command": "cargo test -p deepseek-tui --lib",
1785 "cwd": ".",
1786 "exit_code": 0,
1787 "status": "passed",
1788 "classification": "passed",
1789 "duration_ms": 1,
1790 "summary": "ok",
1791 "log_path": null,
1792 "recorded_at": Utc::now()
1793 }
1794 }
1795 }),
1796 )
1797 .await?;
1798
1799 assert_eq!(updated.gates.len(), 1);
1800 assert_eq!(updated.gates[0].classification, "passed");
1801 Ok(())
1802 }
1803
1804 #[tokio::test]
1805 async fn cancel_running_task_marks_canceled() -> Result<()> {
1806 let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
1807 let manager =
1808 TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await?;
1809
1810 let task = manager
1811 .add_task(NewTaskRequest::from_prompt("test cancellation"))
1812 .await?;
1813
1814 sleep(Duration::from_millis(10)).await;
1815 let _ = manager.cancel_task(&task.id).await?;
1816 let finished = wait_for_terminal_state(&manager, &task.id, Duration::from_secs(3)).await?;
1817 assert_eq!(finished.status, TaskStatus::Canceled);
1818 Ok(())
1819 }
1820
1821 #[tokio::test]
1822 async fn rejects_newer_task_schema_on_recovery() -> Result<()> {
1823 let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4()));
1824 let manager =
1825 TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
1826 .await?;
1827
1828 let task = manager
1829 .add_task(NewTaskRequest::from_prompt("test schema gate"))
1830 .await?;
1831 let _ = wait_for_terminal_state(&manager, &task.id, Duration::from_secs(3)).await?;
1832 drop(manager);
1833
1834 let task_path = root.join("tasks").join(format!("{}.json", task.id));
1835 let mut value: serde_json::Value = serde_json::from_str(&fs::read_to_string(&task_path)?)?;
1836 value["schema_version"] = serde_json::json!(999);
1837 fs::write(&task_path, serde_json::to_string_pretty(&value)?)?;
1838
1839 match TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await {
1840 Ok(_) => panic!("manager should reject newer task schema"),
1841 Err(err) => assert!(err.to_string().contains("newer than supported")),
1842 }
1843 Ok(())
1844 }
1845 }
1846
1846 lines RUST