claude_pool/types.rs
1//! Core types for claude-pool.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8/// Current time in milliseconds since epoch.
9pub fn now_ms() -> u64 {
10 SystemTime::now()
11 .duration_since(UNIX_EPOCH)
12 .unwrap_or_default()
13 .as_millis() as u64
14}
15
16// Re-export shared types from claude-wrapper so consumers don't need
17// to depend on both crates for basic config.
18pub use claude_wrapper::types::{Effort, PermissionMode};
19
20// ── Identifiers ──────────────────────────────────────────────────────
21
22/// Unique identifier for a task.
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct TaskId(pub String);
25
26/// Unique identifier for a slot.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct SlotId(pub String);
29
30// ── Slot types ─────────────────────────────────────────────────────
31
32/// Slot persistence mode.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum SlotMode {
36 /// Persistent slots stay alive across tasks, resuming sessions.
37 #[default]
38 Persistent,
39 /// Ephemeral slots are created per task and destroyed after.
40 Ephemeral,
41}
42
43/// Configuration for dynamic slot pool scaling.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ScalingConfig {
46 /// Minimum number of slots (default: 1).
47 pub min_slots: usize,
48 /// Maximum number of slots (default: 16).
49 pub max_slots: usize,
50}
51
52impl Default for ScalingConfig {
53 fn default() -> Self {
54 Self {
55 min_slots: 1,
56 max_slots: 16,
57 }
58 }
59}
60
61/// Configuration that applies to all slots by default.
62///
63/// Individual slots can override any of these fields via [`SlotConfig`].
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PoolConfig {
66 /// Claude model to use (e.g. "claude-haiku-4-5-20251001").
67 pub model: Option<String>,
68
69 /// Permission mode for slots.
70 pub permission_mode: Option<PermissionMode>,
71
72 /// Maximum turns per task.
73 pub max_turns: Option<u32>,
74
75 /// System prompt prepended to all slot tasks.
76 pub system_prompt: Option<String>,
77
78 /// Allowed tools for slots.
79 pub allowed_tools: Vec<String>,
80
81 /// MCP servers available to slots.
82 pub mcp_servers: HashMap<String, serde_json::Value>,
83
84 /// Default effort level for slots (maps to `--effort`).
85 pub effort: Option<Effort>,
86
87 /// Fallback model to use if the primary model fails.
88 pub fallback_model: Option<String>,
89
90 /// Total budget cap for the pool in microdollars.
91 /// When cumulative spend across all slots reaches this limit,
92 /// new tasks are rejected with [`crate::Error::BudgetExhausted`].
93 pub budget_microdollars: Option<u64>,
94
95 /// Default slot mode.
96 pub slot_mode: SlotMode,
97
98 /// Maximum number of restarts per slot before marking as errored.
99 pub max_restarts: u32,
100
101 /// Enable git worktree isolation for slots.
102 pub worktree_isolation: bool,
103
104 /// Maximum time to wait for an idle slot before failing a task (in seconds).
105 pub slot_assignment_timeout_secs: u64,
106
107 /// Dynamic scaling configuration (min/max bounds).
108 pub scaling: ScalingConfig,
109
110 /// Enable unattended mode: use stricter permission defaults to prevent prompts.
111 /// When true, defaults to `DontAsk` permission mode if not explicitly set.
112 pub unattended_mode: bool,
113
114 /// If true, detect permission prompt patterns in stderr and provide actionable errors.
115 pub detect_permission_prompts: bool,
116
117 /// Enable the background supervisor loop for slot health monitoring.
118 ///
119 /// When enabled, the supervisor periodically checks for errored slots and
120 /// restarts them automatically (up to [`max_restarts`](Self::max_restarts)).
121 pub supervisor_enabled: bool,
122
123 /// Interval in seconds between supervisor health checks (default: 30).
124 ///
125 /// Only used when [`supervisor_enabled`](Self::supervisor_enabled) is true.
126 pub supervisor_interval_secs: u64,
127
128 /// Use `--strict-mcp-config` when passing MCP config to slots.
129 ///
130 /// Prevents slots from inheriting the coordinator's `.mcp.json`, which
131 /// avoids accidental recursive pool calls (a slot invoking `pool_run` on itself).
132 /// Default: `true`.
133 pub strict_mcp_config: bool,
134
135 /// Base directory for git worktrees (chains and slot isolation).
136 ///
137 /// Defaults to `.claude/pool-worktrees/` under the repo root, which keeps
138 /// worktrees within the project directory so Claude's `auto` permission
139 /// mode can write to them. Override if you need worktrees elsewhere.
140 pub worktree_base_dir: Option<PathBuf>,
141}
142
143impl Default for PoolConfig {
144 fn default() -> Self {
145 Self {
146 model: None,
147 permission_mode: Some(PermissionMode::Plan),
148 max_turns: None,
149 system_prompt: None,
150 allowed_tools: Vec::new(),
151 mcp_servers: HashMap::new(),
152 effort: None,
153 fallback_model: None,
154 budget_microdollars: None,
155 slot_mode: SlotMode::default(),
156 max_restarts: 3,
157 worktree_isolation: false,
158 slot_assignment_timeout_secs: 300,
159 scaling: ScalingConfig::default(),
160 unattended_mode: false,
161 detect_permission_prompts: true,
162 supervisor_enabled: false,
163 supervisor_interval_secs: 30,
164 strict_mcp_config: true,
165 worktree_base_dir: None,
166 }
167 }
168}
169
170/// Per-slot configuration overrides.
171///
172/// Any `Some` field here takes precedence over the corresponding field
173/// in [`PoolConfig`].
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct SlotConfig {
176 /// Override model for this slot.
177 pub model: Option<String>,
178
179 /// Override permission mode for this slot.
180 pub permission_mode: Option<PermissionMode>,
181
182 /// Override max turns for this slot.
183 pub max_turns: Option<u32>,
184
185 /// Override system prompt for this slot.
186 pub system_prompt: Option<String>,
187
188 /// Additional allowed tools (merged with global).
189 pub allowed_tools: Option<Vec<String>>,
190
191 /// Additional MCP servers (merged with global).
192 pub mcp_servers: Option<HashMap<String, serde_json::Value>>,
193
194 /// Override effort level for this slot.
195 pub effort: Option<Effort>,
196
197 /// Override fallback model for this slot.
198 pub fallback_model: Option<String>,
199
200 /// Optional name/role for this slot (e.g. "reviewer", "coder").
201 pub role: Option<String>,
202
203 /// Optional human-readable name for the slot (e.g. "reviewer", "writer").
204 pub name: Option<String>,
205
206 /// Optional description of the slot's purpose or responsibilities.
207 pub description: Option<String>,
208
209 /// Override slot assignment timeout (in seconds).
210 pub slot_assignment_timeout_secs: Option<u64>,
211}
212
213/// Per-task configuration overrides.
214///
215/// Applied on top of the slot config for a single task execution. Unlike
216/// [`SlotConfig`], this struct contains only execution parameters — it has
217/// no identity fields (name, role, description) or slot lifecycle settings.
218#[derive(Debug, Clone, Default, Serialize, Deserialize)]
219pub struct TaskOverrides {
220 /// Override model for this task.
221 pub model: Option<String>,
222
223 /// Override permission mode for this task.
224 pub permission_mode: Option<PermissionMode>,
225
226 /// Override max turns for this task.
227 pub max_turns: Option<u32>,
228
229 /// Override system prompt for this task.
230 pub system_prompt: Option<String>,
231
232 /// Additional allowed tools for this task (merged with global and slot).
233 pub allowed_tools: Option<Vec<String>>,
234
235 /// Tools to explicitly disallow for this task.
236 pub disallowed_tools: Option<Vec<String>>,
237
238 /// Built-in tool selection for this task (e.g. "Bash", "Edit", "Read").
239 pub tools: Option<Vec<String>>,
240
241 /// Additional MCP servers for this task (merged with global and slot).
242 pub mcp_servers: Option<HashMap<String, serde_json::Value>>,
243
244 /// Override effort level for this task.
245 pub effort: Option<Effort>,
246
247 /// Override fallback model for this task.
248 pub fallback_model: Option<String>,
249
250 /// JSON schema for structured output validation.
251 pub json_schema: Option<serde_json::Value>,
252
253 /// Maximum budget cap for this task in USD.
254 pub max_budget_usd: Option<f64>,
255}
256
257/// Current state of a slot.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum SlotState {
261 /// Slot is ready to accept a task.
262 Idle,
263 /// Slot is currently executing a task.
264 Busy,
265 /// Slot process has exited or been stopped.
266 Stopped,
267 /// Slot encountered an error and needs attention.
268 Errored,
269}
270
271/// Record of a slot in the pool.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SlotRecord {
274 /// Unique slot identifier.
275 pub id: SlotId,
276
277 /// Current state.
278 pub state: SlotState,
279
280 /// Per-slot config overrides.
281 pub config: SlotConfig,
282
283 /// The task currently being executed, if any.
284 pub current_task: Option<TaskId>,
285
286 /// Claude session ID for session resumption.
287 pub session_id: Option<String>,
288
289 /// Number of tasks completed by this slot.
290 pub tasks_completed: u64,
291
292 /// Cumulative cost in microdollars.
293 pub cost_microdollars: u64,
294
295 /// Number of times this slot has been restarted.
296 pub restart_count: u32,
297
298 /// Git worktree path, if worktree isolation is enabled.
299 pub worktree_path: Option<String>,
300
301 /// Path to the slot's temp `.mcp.json` file, if MCP servers are configured.
302 ///
303 /// Written once per slot (on first task that needs it) and reused across
304 /// subsequent tasks. Cleaned up on pool drain/shutdown.
305 #[serde(skip)]
306 pub mcp_config_path: Option<std::path::PathBuf>,
307}
308
309// ── Task types ───────────────────────────────────────────────────────
310
311/// Current state of a task.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "snake_case")]
314pub enum TaskState {
315 /// Task is waiting for a slot.
316 Pending,
317 /// Task is being executed by a slot.
318 Running,
319 /// Task completed successfully.
320 Completed,
321 /// Task failed.
322 Failed,
323 /// Task was cancelled.
324 Cancelled,
325 /// Task completed but awaits coordinator approval before being considered done.
326 PendingReview,
327}
328
329/// A task submitted to the pool.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct TaskRecord {
332 /// Unique task identifier.
333 pub id: TaskId,
334
335 /// The prompt/instruction for the task.
336 pub prompt: String,
337
338 /// Current state.
339 pub state: TaskState,
340
341 /// Slot assigned to this task.
342 pub slot_id: Option<SlotId>,
343
344 /// Task result, available when state is `Completed` or `Failed`.
345 pub result: Option<TaskResult>,
346
347 /// Optional tags for filtering and grouping.
348 pub tags: Vec<String>,
349
350 /// Per-task config overrides (takes precedence over slot and global config).
351 pub config: Option<TaskOverrides>,
352
353 /// When true, completed tasks transition to `PendingReview` instead of `Completed`.
354 #[serde(default)]
355 pub review_required: bool,
356
357 /// Maximum number of rejections before the task is marked as failed (default: 3).
358 #[serde(default = "default_max_rejections")]
359 pub max_rejections: u32,
360
361 /// Number of times this task has been rejected and re-queued.
362 #[serde(default)]
363 pub rejection_count: u32,
364
365 /// The original prompt before any rejection feedback was appended.
366 #[serde(default)]
367 pub original_prompt: Option<String>,
368
369 /// When this task was submitted (millis since epoch).
370 #[serde(default)]
371 pub created_at_ms: Option<u64>,
372
373 /// When this task started executing (millis since epoch).
374 #[serde(default)]
375 pub started_at_ms: Option<u64>,
376
377 /// When this task completed/failed/was cancelled (millis since epoch).
378 #[serde(default)]
379 pub completed_at_ms: Option<u64>,
380}
381
382fn default_max_rejections() -> u32 {
383 3
384}
385
386/// The result of a completed task.
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct TaskResult {
389 /// The text output from Claude.
390 pub output: String,
391
392 /// Whether the task succeeded.
393 pub success: bool,
394
395 /// Cost in microdollars.
396 pub cost_microdollars: u64,
397
398 /// Number of turns used.
399 pub turns_used: u32,
400
401 /// Wall-clock execution time in milliseconds.
402 #[serde(default)]
403 pub elapsed_ms: u64,
404
405 /// Model that executed this task.
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub model: Option<String>,
408
409 /// Session ID from the execution.
410 pub session_id: Option<String>,
411
412 /// On failure: the CLI command that was run.
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub failed_command: Option<String>,
415
416 /// On failure: the exit code from the CLI.
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub exit_code: Option<i32>,
419
420 /// On failure: stderr output from the CLI.
421 #[serde(skip_serializing_if = "Option::is_none")]
422 pub stderr: Option<String>,
423
424 /// Whether this task exceeded its per-task budget cap.
425 ///
426 /// Set by the pool after execution if the task's actual cost exceeded
427 /// the `max_budget_usd` from its [`TaskOverrides`]. The CLI enforces
428 /// the cap during execution, so this primarily flags tasks that ran
429 /// up against their limit.
430 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
431 pub budget_exceeded: bool,
432}
433
434impl TaskResult {
435 /// Create a successful task result.
436 pub fn success(output: impl Into<String>, cost_microdollars: u64, turns_used: u32) -> Self {
437 Self {
438 output: output.into(),
439 success: true,
440 cost_microdollars,
441 turns_used,
442 elapsed_ms: 0,
443 model: None,
444 session_id: None,
445 failed_command: None,
446 exit_code: None,
447 stderr: None,
448 budget_exceeded: false,
449 }
450 }
451
452 /// Create a failed task result.
453 pub fn failure(output: impl Into<String>) -> Self {
454 Self {
455 output: output.into(),
456 success: false,
457 cost_microdollars: 0,
458 turns_used: 0,
459 elapsed_ms: 0,
460 model: None,
461 session_id: None,
462 failed_command: None,
463 exit_code: None,
464 stderr: None,
465 budget_exceeded: false,
466 }
467 }
468
469 /// Set the model that executed this task.
470 pub fn with_model(mut self, model: impl Into<String>) -> Self {
471 self.model = Some(model.into());
472 self
473 }
474
475 /// Set the elapsed execution time in milliseconds.
476 pub fn with_elapsed_ms(mut self, elapsed_ms: u64) -> Self {
477 self.elapsed_ms = elapsed_ms;
478 self
479 }
480
481 /// Set the session ID.
482 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
483 self.session_id = Some(session_id.into());
484 self
485 }
486
487 /// Set failure details (command, exit code, stderr).
488 pub fn with_failure_details(
489 mut self,
490 command: Option<String>,
491 exit_code: Option<i32>,
492 stderr: Option<String>,
493 ) -> Self {
494 self.failed_command = command;
495 self.exit_code = exit_code;
496 self.stderr = stderr;
497 self
498 }
499}
500
501impl TaskRecord {
502 /// Create a new pending task record with timestamps.
503 pub fn new_pending(id: TaskId, prompt: impl Into<String>) -> Self {
504 Self {
505 id,
506 prompt: prompt.into(),
507 state: TaskState::Pending,
508 slot_id: None,
509 result: None,
510 tags: vec![],
511 config: None,
512 review_required: false,
513 max_rejections: 3,
514 rejection_count: 0,
515 original_prompt: None,
516 created_at_ms: Some(now_ms()),
517 started_at_ms: None,
518 completed_at_ms: None,
519 }
520 }
521
522 /// Set tags.
523 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
524 self.tags = tags;
525 self
526 }
527
528 /// Set per-task config overrides.
529 pub fn with_config(mut self, config: Option<TaskOverrides>) -> Self {
530 self.config = config;
531 self
532 }
533
534 /// Enable review-required mode.
535 pub fn with_review(mut self, max_rejections: u32) -> Self {
536 self.review_required = true;
537 self.max_rejections = max_rejections;
538 self.original_prompt = Some(self.prompt.clone());
539 self
540 }
541
542 /// Transition the task to a new state, setting timestamps automatically.
543 ///
544 /// - `Running`: sets `started_at_ms`
545 /// - `Completed`, `Failed`, `Cancelled`, `PendingReview`: sets `completed_at_ms`
546 pub fn transition_to(&mut self, state: TaskState) {
547 self.state = state;
548 let now = now_ms();
549 match state {
550 TaskState::Running => {
551 self.started_at_ms = Some(now);
552 }
553 TaskState::Completed
554 | TaskState::Failed
555 | TaskState::Cancelled
556 | TaskState::PendingReview => {
557 self.completed_at_ms = Some(now);
558 }
559 TaskState::Pending => {}
560 }
561 }
562}
563
564/// Filter criteria for listing tasks.
565#[derive(Debug, Clone, Default, Serialize, Deserialize)]
566pub struct TaskFilter {
567 /// Filter by state.
568 pub state: Option<TaskState>,
569
570 /// Filter by slot.
571 pub slot_id: Option<SlotId>,
572
573 /// Filter by tags (any match).
574 pub tags: Option<Vec<String>>,
575}
576
577/// Aggregated metrics for the current pool session.
578///
579/// Provides developer-focused insights: spend tracking, task timing,
580/// and sizing data useful for optimizing pool usage patterns.
581#[derive(Debug, Clone, Default, Serialize, Deserialize)]
582pub struct SessionMetrics {
583 /// Total number of tasks submitted this session.
584 pub total_tasks: u64,
585 /// Number of completed tasks.
586 pub completed_tasks: u64,
587 /// Number of failed tasks.
588 pub failed_tasks: u64,
589 /// Number of cancelled tasks.
590 pub cancelled_tasks: u64,
591 /// Number of currently running tasks.
592 pub running_tasks: u64,
593 /// Number of pending tasks.
594 pub pending_tasks: u64,
595
596 /// Total spend across all tasks in microdollars.
597 pub total_spend_microdollars: u64,
598 /// Average cost per completed task in microdollars.
599 pub avg_cost_microdollars: u64,
600 /// Highest single-task cost in microdollars.
601 pub max_cost_microdollars: u64,
602
603 /// Average execution time for completed tasks in milliseconds.
604 pub avg_elapsed_ms: u64,
605 /// Median execution time for completed tasks in milliseconds.
606 pub median_elapsed_ms: u64,
607 /// Maximum execution time for completed tasks in milliseconds.
608 pub max_elapsed_ms: u64,
609 /// Minimum execution time for completed tasks in milliseconds.
610 pub min_elapsed_ms: u64,
611
612 /// Average number of turns per completed task.
613 pub avg_turns: f64,
614
615 /// Breakdown of tasks by model (count only).
616 pub tasks_by_model: HashMap<String, u64>,
617
618 /// Detailed per-model metrics.
619 pub model_breakdown: Vec<ModelMetrics>,
620
621 /// Session start time (millis since epoch).
622 pub session_start_ms: u64,
623 /// Session duration so far in milliseconds.
624 pub session_duration_ms: u64,
625}
626
627/// Per-model aggregated metrics.
628#[derive(Debug, Clone, Default, Serialize, Deserialize)]
629pub struct ModelMetrics {
630 /// Model identifier.
631 pub model: String,
632 /// Number of tasks run on this model.
633 pub task_count: u64,
634 /// Total spend for this model in microdollars.
635 pub total_cost_microdollars: u64,
636 /// Average cost per task in microdollars.
637 pub avg_cost_microdollars: u64,
638 /// Average execution time in milliseconds.
639 pub avg_elapsed_ms: u64,
640 /// Total turns used by this model.
641 pub total_turns: u64,
642}
643
644/// Filter criteria for session metrics queries.
645#[derive(Debug, Clone, Default, Serialize, Deserialize)]
646pub struct MetricsFilter {
647 /// Only include tasks created after this time (millis since epoch).
648 pub since_ms: Option<u64>,
649 /// Only include tasks created before this time (millis since epoch).
650 pub until_ms: Option<u64>,
651 /// Only include tasks with these tags (any match).
652 pub tags: Option<Vec<String>>,
653 /// Only include tasks that ran on this model.
654 pub model: Option<String>,
655}