ai-dispatch 10.9.0

Multi-AI CLI team orchestrator
// Task-centric domain structs for aid task storage and display.
// Exports: Task, Workgroup, Finding, TaskEvent, TaskFilter, CompletionInfo.
// Deps: chrono, serde, and parent `crate::types` enums/IDs.

use chrono::{DateTime, Local};
use serde::Serialize;

use super::{
    provider_for_cli, AgentKind, AttributionSource, DeliveryAssessment, EventKind, Route, TaskId,
    TaskStatus, VerifyStatus, WorkgroupId,
};

#[derive(Debug, Clone, Serialize)]
pub struct Task {
    pub id: TaskId,
    pub agent: AgentKind,
    pub custom_agent_name: Option<String>,
    pub prompt: String,
    pub resolved_prompt: Option<String>,
    pub category: Option<String>,
    pub status: TaskStatus,
    pub parent_task_id: Option<String>,
    pub workgroup_id: Option<String>,
    pub caller_kind: Option<String>,
    pub caller_session_id: Option<String>,
    pub agent_session_id: Option<String>,
    pub repo_path: Option<String>,
    pub worktree_path: Option<String>,
    pub worktree_branch: Option<String>,
    pub final_head_sha: Option<String>,
    pub final_branch: Option<String>,
    pub start_sha: Option<String>,
    pub log_path: Option<String>,
    pub output_path: Option<String>,
    pub tokens: Option<i64>,
    pub prompt_tokens: Option<i64>,
    pub duration_ms: Option<i64>,
    /// The model aid dispatched with — a request, not an outcome. Set at
    /// dispatch from `--model`, the configured default, budget mode or smart
    /// routing, and kept as aid passed it even when the CLI refused to serve
    /// it: `t-bd455a68` asked the `claude` CLI for `gemini-3.6-flash-low` and
    /// failed, and the request is still the honest record of what was asked.
    pub requested_model: Option<String>,
    /// The model the CLI reported it actually ran. `None` means the CLI never
    /// said so — which is not the same as the requested model having run.
    /// Collapsing the two is what stored a cursor model on an `agy` task
    /// (`t-702f7bcb`) and `auto`, a router, as though it were a model.
    ///
    /// Cost, capability history and model-level routing must read this, never
    /// `requested_model`. Per-family quota marking is the one legitimate reader
    /// of the request: it asks which family aid *aimed at*, and plain-text CLIs
    /// such as agy never echo a model at all.
    pub observed_model: Option<String>,
    /// How `observed_model` was established. Always `None` when
    /// `observed_model` is `None` — the two move together.
    pub attribution_source: Option<AttributionSource>,
    pub cost_usd: Option<f64>,
    pub exit_code: Option<i32>,
    pub created_at: DateTime<Local>,
    pub completed_at: Option<DateTime<Local>>,
    pub verify: Option<String>,
    pub verify_status: VerifyStatus,
    pub pending_reason: Option<String>,
    pub read_only: bool,
    pub budget: bool,
    pub audit_verdict: Option<String>,
    pub audit_report_path: Option<String>,
    pub delivery_assessment: Option<DeliveryAssessment>,
}

impl Task {
    /// The model an outcome may be attributed to: what the CLI reported, never
    /// what aid asked for. `None` means nobody knows, and it must stay unknown
    /// — capability history, per-model success rates and model-level routing
    /// read this, and a guessed model there poisons the advice built on it.
    pub fn attributed_model(&self) -> Option<&str> {
        self.observed_model.as_deref()
    }

    /// The model, but only when the CLI itself said so. Capability scoring and
    /// an agent's learned default model read this rather than
    /// `attributed_model`, because a model inferred from a run merely not
    /// failing is not evidence that model performed well — or even that a
    /// substitution did not happen behind a successful exit.
    pub fn conclusive_model(&self) -> Option<&str> {
        self.attribution_source
            .filter(|source| source.is_conclusive())
            .and(self.observed_model.as_deref())
    }

    /// The model to price against, and the model a derived dispatch should ask
    /// for again: the observation when there is one, otherwise the original
    /// request. The fallback is legitimate here only because both values are
    /// stored, so a reader can see which basis was used.
    pub fn costing_model(&self) -> Option<&str> {
        self.observed_model
            .as_deref()
            .or(self.requested_model.as_deref())
    }

    /// What to show a human. A request that was never confirmed is marked, and
    /// an observation that contradicts the request is shown as both — that
    /// disagreement means the CLI served something other than what was asked.
    pub fn display_model(&self) -> Option<String> {
        format_model_display(
            self.observed_model.as_deref(),
            self.requested_model.as_deref(),
            self.attribution_source,
        )
    }

    /// The structured route for this task. Model is the observation only — a
    /// request that was never confirmed stays `None` rather than being filled in.
    pub fn route(&self) -> Route {
        Route::for_cli(self.agent).with_model(self.attributed_model())
    }

    /// Human route label: `cli/provider/model` with attribution on the model
    /// segment. An unknown model stays `unknown` — never a default or the CLI
    /// name. Provider comes from `provider_for_cli`, the single source.
    pub fn display_route(&self) -> String {
        let (provider, _) = provider_for_cli(self.agent);
        let model = self
            .display_model()
            .unwrap_or_else(|| "unknown".to_string());
        format!(
            "{}/{}/{}",
            self.agent_display_name(),
            provider.as_str(),
            model
        )
    }

    pub fn agent_display_name(&self) -> &str {
        if self.agent == AgentKind::Custom {
            self.custom_agent_name.as_deref().unwrap_or("custom")
        } else {
            self.agent.as_str()
        }
    }

    pub fn delivery_assessment(&self) -> Option<DeliveryAssessment> {
        self.delivery_assessment
    }

    pub fn has_verify_failure(&self) -> bool {
        self.verify_status == VerifyStatus::Failed
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct Workgroup {
    pub id: WorkgroupId,
    pub name: String,
    pub shared_context: String,
    pub created_by: Option<String>,
    pub created_at: DateTime<Local>,
    pub updated_at: DateTime<Local>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Finding {
    pub id: i64,
    pub workgroup_id: String,
    pub content: String,
    pub source_task_id: Option<String>,
    pub severity: Option<String>,
    pub title: Option<String>,
    pub file: Option<String>,
    pub lines: Option<String>,
    pub category: Option<String>,
    pub confidence: Option<String>,
    pub verdict: Option<String>,
    pub score: Option<String>,
    pub note: Option<String>,
    pub created_at: DateTime<Local>,
    pub updated_at: Option<DateTime<Local>>,
}

#[derive(Debug, Clone, Serialize)]
pub struct TaskEvent {
    pub task_id: TaskId,
    pub timestamp: DateTime<Local>,
    pub event_kind: EventKind,
    pub detail: String,
    pub metadata: Option<serde_json::Value>,
}

impl TaskEvent {
    /// Untruncated detail: parsers stash text over the display cap in
    /// metadata under `"full"`.
    pub fn full_detail(&self) -> &str {
        self.metadata
            .as_ref()
            .and_then(|meta| meta.get("full"))
            .and_then(|value| value.as_str())
            .unwrap_or(&self.detail)
    }
}

#[derive(Debug, Clone, Copy)]
pub enum TaskFilter {
    All,
    Active,
    Running,
    Today,
}

#[derive(Debug, Clone)]
pub struct CompletionInfo {
    pub tokens: Option<i64>,
    pub status: TaskStatus,
    /// The model the CLI named in its own output, if it named one at all. An
    /// adapter must never put the dispatched request here.
    pub model: Option<String>,
    pub cost_usd: Option<f64>,
    pub exit_code: Option<i32>,
}

/// Renders model attribution for humans. An unconfirmed request is marked with
/// `?` so a reader can tell a guess from an observation at a glance, and a
/// disagreement is shown in full because it means the CLI served something
/// other than what was asked for.
pub fn format_model_display(
    observed: Option<&str>,
    requested: Option<&str>,
    source: Option<AttributionSource>,
) -> Option<String> {
    match (observed, requested) {
        (Some(observed), Some(requested)) if observed != requested => {
            Some(format!("{observed} (asked {requested})"))
        }
        // Inferred from the run not failing rather than from the CLI saying so.
        // Rendering it identically to an echo would hide the weaker evidence,
        // which is the whole reason the grade is stored.
        (Some(observed), _) if source == Some(AttributionSource::ConfirmedBySuccess) => {
            Some(format!("{observed} (inferred)"))
        }
        (Some(observed), _) => Some(observed.to_string()),
        (None, Some(requested)) => Some(format!("{requested}?")),
        (None, None) => None,
    }
}

#[cfg(test)]
#[path = "task_display_tests.rs"]
mod tests;